{"owner":"chen08209","repo":"FlClash","hasSkills":true,"totalSkillsCount":40,"totalTokensCount":38772,"categories":["anthropic-skill","root-instruction","project-spec","codex-rule","claude-rule","plugin-manifest"],"hasMcp":false,"mcpConfig":null,"found":[".agents/skills/core-platform/SKILL.md",".agents/skills/localization/SKILL.md",".agents/skills/pre-commit-quality-review/SKILL.md","AGENTS.md",".agents/skills/ui-work/SKILL.md",".agents/architecture.md",".agents/rules.md",".codex/config.toml",".agents/skills.md",".claude/skills/localization",".claude/skills/pre-commit-quality-review",".claude/skills/core-platform",".claude/skills/ui-work","plugins/code_forge/README.md","plugins/code_forge/analysis_options.yaml","plugins/code_forge/pubspec.yaml","plugins/proxy/CHANGELOG.md","plugins/proxy/README.md","plugins/proxy/analysis_options.yaml","plugins/proxy/pubspec.yaml","plugins/rust_api/CHANGELOG.md","plugins/rust_api/CLAUDE.md","plugins/rust_api/README.md","plugins/rust_api/analysis_options.yaml","plugins/rust_api/flutter_rust_bridge.yaml","plugins/rust_api/pubspec.yaml","plugins/setup/analysis_options.yaml","plugins/setup/pubspec.yaml","plugins/tray/README.md","plugins/tray/analysis_options.yaml","plugins/tray/pubspec.yaml","plugins/wifi_ssid/analysis_options.yaml","plugins/wifi_ssid/pubspec.yaml","plugins/window/README.md","plugins/window/analysis_options.yaml","plugins/window/pubspec.yaml","plugins/setup/setup_hooks/analysis_options.yaml","plugins/setup/setup_hooks/pubspec.yaml","plugins/proxy/windows/.vscode/settings.json","plugins/rust_api/rust/tests/fixtures/profile_config.json"],"skills":{".agents/skills/core-platform/SKILL.md":"---\nname: core-platform\ndescription: 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.\n---\n\n# Core And Platform\n\n## When To Use\n\nUse this for changes touching `lib/core/`, `lib/manager/`, `core/`, `services/helper/`, Android app/service modules,\nbuild hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior.\n\n## Read First\n\n- `.agents/rules.md`: Core API Safety and Lifecycle Rules are binding. They carry the mihomo-fork patch budget, the\n  JSON envelope and `core/message.go` queue rules, gomobile callback rules, `BootGuard`, tray, and SSID rules, and every\n  lifecycle invariant (latest intent wins, desktop ownership through a `CoreProcessLease`, terminal `close()`,\n  optimistic Android commands arbitrated by `ServiceState`, callbacks are not user intent, `goAsync()` finishes once).\n- `.agents/setting-changes.md`: a setting the Core or the Android VPN reads is patched live, re-applied with the\n  profile, or left to a restart the user confirms; it says which, and what adding a setting to each path takes.\n- `.agents/architecture.md`: Core Integration, Lifecycle Ownership And Convergence, Core Protocol And Event Delivery,\n  Safe Mode, Build System, and Rust Helper Service describe the current mechanisms, including the Helper protocol,\n  the applied/coalesced/superseded outcomes, and the unconfirmed-exit lease.\n\n## Owners\n\nEvery behavior here has one authoritative owner, and a change lands there:\n\n- Shared facade/protocol: `lib/core/controller.dart`, `lib/core/interface.dart`, and `lib/core/method.dart`.\n- Android Core connection: `lib/core/lib.dart`, `lib/plugins/service.dart`, and Android `ServicePlugin`.\n- Android start/stop intent: `ServiceState`; binding/process-time bookkeeping: `ServiceController`.\n- Desktop composition: `lib/core/service.dart`; lifecycle/process ownership: `lib/core/desktop/lifecycle.dart`.\n- Desktop IPC/RPC: `lib/core/desktop/transport.dart` and `lib/core/desktop/rpc_client.dart`.\n- Desktop launch ownership: `lib/core/desktop/launcher.dart`; Windows and Linux Helper HTTP contract:\n  `lib/core/desktop/helper_client.dart` and `services/helper/`.\n- Flutter orchestration: `lib/providers/actions/core.dart` and `system.dart`; application exit:\n  `SystemExitCoordinator`; UI/event observation: `lib/manager/`.\n\nAn owner has more entry paths than the UI: provider calls, Quick Settings, notification actions, Always-on VPN, revoke\ncallbacks, application exit, and crash/disconnect recovery all reach it. Always-on startup is explicit through\n`VPN_START_REQUESTED` and revoke through `VPN_REVOKED`; service creation or destruction is never read as intent.\n\n## Verification\n\nFocused tests live at the narrowest layer, and the matching commands are in `.agents/commands.md`:\n\n- Desktop lifecycle/transport/RPC: `test/core/desktop/` plus `test/core/service_test.dart`.\n- Cross-language envelopes/events: `test/core/protocol_contract_test.dart` and `CGO_ENABLED=0 go test .`.\n- Provider/exit convergence: `test/providers/action_test.dart` and `test/providers/system_action_test.dart`.\n- Android Kotlin: compile each touched Gradle module with JDK 17.\n- Helper: `cargo fmt --check` and `cargo test`; the `windows-service` feature only builds on Windows.\n\nState host gaps explicitly. Always-on VPN, VPN permission, system revoke, named-pipe peer identity, polkit and systemd\non Linux, and Windows Service Control Manager behavior need their real platform even when portable tests pass.\n\n## Pitfalls\n\n- A `200` from Helper `/stop` means the Core is gone. When termination cannot be confirmed the Helper keeps the child\n  and answers `coreStopFailed`, and `/start` reports the same code instead of spawning a replacement. Keep that code\n  out of the Dart pre-spawn fallback set in `helper_client.dart`, or the direct launch races a Core the Helper still\n  owns.\n- The Helper owns executable integrity: Core SHA256 validation and the protocol stay identical across Flutter build\n  modes, so a debug build is not exempt.\n- `plugins/setup/` is a build harness, not a Dart API plugin, and its hooks can trigger Go or Rust compilation\n  indirectly through Flutter platform builds; a missing toolchain fails `flutter test`, not only `flutter build`.\n",".agents/skills/localization/SKILL.md":"---\nname: localization\ndescription: Use when changing FlClash UI text, scanning for hardcoded Chinese strings, updating ARB localization, or fixing generated locale output in this repository.\n---\n\n# Localization\n\n## When To Use\n\nUse this for localization work in this repository, especially hardcoded UI text in `lib/`, ARB updates, missing translations, or generated `lib/l10n/` output that does not match source ARB values.\n\nDo not use this for README translation sync or manual edits to generated localization Dart.\n\n## Workflow\n\n1. Scan user-facing Dart text before opening many files. `flutter_intl` reads `arb/` and writes `lib/l10n/`:\n\n   ```bash\n   rg -n \"[\\\\p{Han}]\" lib -g '!lib/l10n/intl/**' -g '!lib/**/generated/**'\n   ```\n\n2. Inspect the smallest relevant call sites and nearby ARB keys.\n3. Add or update every source ARB:\n   - `arb/intl_en.arb`\n   - `arb/intl_zh_CN.arb`\n   - `arb/intl_ja.arb`\n   - `arb/intl_ru.arb`\n   - `arb/intl_fr.arb`\n   - `arb/intl_es.arb`\n   - `arb/intl_ko.arb`\n4. Replace inline strings with existing project accessors:\n   - Widgets with `BuildContext`: `context.appLocalizations.key` from `lib/common/context.dart`.\n   - Controllers/providers/non-widget code: `currentAppLocalizations.key` from `lib/common/app_localizations.dart`.\n5. Regenerate:\n\n   ```bash\n   dart run intl_utils:generate\n   ```\n\n6. Run `flutter analyze --no-fatal-infos` and `flutter test test/l10n test/lint/dynamic_message_key_test.dart`. The\n   lint test checks that every locale carries the same keys and that no `Intl.message` builds a key at runtime; labels\n   for enum values go through the switches in `lib/common/l10n_labels.dart`, as the ARB section of `.agents/rules.md`\n   explains.\n7. Re-run the Han-text scan for targeted strings.\n\n## Pitfalls\n\n- A locale that still shows English lacks the key or carries an English value in its source ARB; fix that ARB and\n  regenerate. Do not edit generated Dart.\n- Ignore `lib/l10n/intl/**` and `lib/**/generated/**` during text scans.\n",".agents/skills/pre-commit-quality-review/SKILL.md":"---\nname: pre-commit-quality-review\ndescription: Use when the user asks to commit, amend, or squash code, or requests a pre-commit structural quality review.\n---\n\n# Pre-commit Quality Review\n\nCheck that iterative edits converged into a clear, cohesive, maintainable implementation before they become a commit,\nso later code review is not spent on basic structure. `AGENTS.md` and `.agents/rules.md` apply throughout. This skill\ngrants no authority: commit, push, and history rewrites stay under the user's existing authorization, and silence is\nnot consent.\n\n## Triage\n\nMost commits stop here. Decide from the change summary, the diff, and the local context it needs; never start with a\nrepository-wide scan.\n\n- **Skip** formatting, mechanical renames, generated output, copy changes, and simple local fixes. They go through the\n  normal commit flow without extra confirmation. Say the review was skipped, never that it passed.\n- **Review** when a core implementation is replaced; when responsibilities, dependency direction, or public abstractions\n  change materially; when accumulated patches added branches, duplicate paths, or indirection; or when new code draws\n  responsibility boundaries. A small change can move a critical responsibility, so line count is not a trigger.\n- An explicit structural review request bypasses triage.\n\nThe snapshot under review is the intended commit compared with its baseline:\n\n| Operation | Baseline |\n|---|---|\n| Regular commit | `HEAD` |\n| Amend | Parent of the commit being replaced |\n| Squash | Version preceding the agreed range |\n| Initial commit | Empty tree |\n\nAsk when the range or parent is materially ambiguous instead of assuming the whole branch. Read staged versions of\npartially staged files; an unstaged fix is not part of the pending commit. Preserve unrelated changes and partial\nstaging.\n\n## Review the Final Structure\n\nScope the review to the responsibility boundaries this change touched. Read the complete final implementation of the\naffected units and compare before and after, not only the last patch. Report issues this change introduced or\nworsened; untouched historical debt is not a pre-commit requirement. Follow a call or dependency path only as far as a\nconcrete concern needs, and disclose missing essential context rather than claiming a complete pass.\n\nFocus on:\n\n- **Responsibilities and cohesion:** units mixing responsibilities that change independently; orchestration tangled\n  with implementation detail; responsibilities in layers that should not own them.\n- **Dependencies and abstractions:** reversed or circular dependencies, forwarding layers, abstractions that isolate\n  nothing; similar names or interfaces hiding different responsibilities.\n- **Complexity and readability:** nested conditions, repeated fallbacks, boolean parameter combinations, mutually\n  dependent helpers; names and decomposition that do not reduce reading effort.\n- **Duplication and consistency:** reimplemented existing capabilities, parallel implementations or duplicate state,\n  scattered ownership of error handling, cleanup, or state updates.\n- **Remnants:** obsolete branches, compatibility layers, unused parameters, commented-out code, comments the rewrite\n  invalidated; coupling that makes existing test boundaries hard to use.\n\nNot findings: length, a missing design pattern, naming taste, or anything without code evidence and a real\nmaintenance cost. Do not add cross-layer coupling to remove a few similar lines, rewrite stable code for uniformity,\nor demand abstractions for speculative needs.\n\nBusiness logic, UI behavior, performance, security, platform compatibility, and full functional review are out of\nscope. Report an incidentally found serious defect separately through the normal defect workflow, without hiding it or\nwidening this review.\n\n## Findings and the User's Decision\n\nA finding qualifies only when all three hold:\n\n- Before-and-after code and the necessary call relationships show this change introduced or worsened it.\n- The maintenance cost is concrete, such as updating one rule in two places or touching unrelated layers for one\n  responsibility. Clarity or elegance alone is not enough.\n- A minimal proposal fits existing constraints, with a benefit not offset by new coupling, indirection, or scope.\n\nWith no qualifying finding, state the reviewed scope and conclusion in a sentence or two and continue under the\nexisting commit authorization. Do not ask for confirmation because a review happened, and do not collect optional\nimprovements into a suggestion list.\n\nWith qualifying findings, finish the read-only review and independent checks first, merge findings that share a cause,\nand give for each: file, symbol, and location with how this change introduced it; the concrete maintenance cost; the\nsmallest proposal with its scope and any behavioral impact. Then **pause before creating the commit and ask whether to\noptimize first; do not edit or commit until the user answers.** Attribute the pause to this skill by file path so the\nuser recognizes their configured workflow, for example: \"The structural issues above support consolidating the\nduplicate state-update paths. Should I make that change before committing?\"\n\nExisting explicit authorization for these findings is enough to act without asking again. The user may accept all,\nsome, or none. Keep accepted findings and tradeoffs in the task context only, and do not re-ask because files were\nrestaged, a hash changed, or unrelated edits landed; reassess only when relevant code changes invalidate the tradeoff.\n\n## Resolve and Finish\n\nOnce authorized, make the smallest adjustments that resolve the accepted findings while preserving behavior and task\nscope; explain separately if either must change. Verify only the accepted findings and the direct effects of the\nadjustments, without another search for improvements. Raise a new finding only with evidence that meets the threshold\nand exceeds the existing authorization.\n\nBefore committing, recheck status and the actual staged diff so the pending snapshot matches what was reviewed, and\nreview only the delta of later edits. Run the checks `AGENTS.md` requires for the change: `flutter pub get`,\n`flutter analyze --no-fatal-infos`, and the covering `flutter test` suites listed in `.agents/commands.md`. Reuse\nresults that are still valid, rerun those a later edit invalidated, and never present results from an earlier snapshot\nas verification of the staged one. The `comment-density` and `commit-msg` hooks in `.agents/rules.md` still gate the\ncommit.\n\nReport the reviewed scope, completed improvements or accepted tradeoffs, check results, and any verification limits.\nClaim the commit is done only after it exists.\n","AGENTS.md":"# AGENTS.md\n\nThis file is the entry point for AI coding agents working in this repository. Keep it small: detailed guidance lives under\n`.agents/`, and discoverable repo skills live under `.agents/skills/*/SKILL.md`.\n\n## Start Here\n\nRead these files before making changes:\n\n- [.agents/project.md](.agents/project.md): project overview, versions, and build dependencies.\n- [.agents/commands.md](.agents/commands.md): build, development, code generation, and test commands.\n- [.agents/rules.md](.agents/rules.md): lint, testing, generated-code, and workflow rules.\n\nRead these only when the task touches their area:\n\n- [.agents/architecture.md](.agents/architecture.md): core integration, providers, database, managers, build system, and\n  local plugins.\n- [.agents/setting-changes.md](.agents/setting-changes.md): whether a changed setting is patched live, re-applies the\n  profile, or asks for an Android VPN restart.\n- [.agents/surfaces.md](.agents/surfaces.md): fill levels, selection and status colors, and the few borders that\n  remain, for any change to how a surface is colored.\n- [.agents/agent-config.md](.agents/agent-config.md): how to choose between `AGENTS.md`, `.agents`, skills, Codex config,\n  command rules, and hooks.\n- [.agents/worktrees.md](.agents/worktrees.md): worktree hygiene across Claude Code, Codex, and Gemini.\n- [.agents/flutter-upgrade.md](.agents/flutter-upgrade.md): SDK-pinned workarounds to recheck before changing the\n  Flutter version.\n- [.agents/skills.md](.agents/skills.md): index of repo-scoped skills in `.agents/skills/`.\n\n## Highest Priority Rules\n\n- When the user explicitly requests a scoped, low-risk change, inspect the relevant context and implement it directly.\n  Do not require brainstorming, design documents, implementation plans, multiple-option proposals, or repeated confirmation.\n  Ask only when material ambiguity, destructive impact, additional authority, or scope expansion could change the result.\n- The request sets the scope. A pre-existing bug, cleanup, or refactor the task did not ask for is a follow-up in the\n  summary, not part of the change. Edit a file surgically when a rewrite would give the same result, keep scratch checks\n  out of the repository, and commit tests only where the task asks for them or the neighbouring code already keeps them.\n- Comments are allowed and sometimes necessary; excess is the problem. A comment must carry something the code cannot\n  say — a non-obvious constraint, an upstream behavior being worked around, a reason a reader would otherwise get\n  wrong. Never restate what the code does, narrate the change you just made, record what the code used to be, or\n  annotate step by step; a block that seems to need a comment per line needs better names or a smaller decomposition.\n  Keep density near the repository's own: healthy changes here sit at or under 3.6%, and a `comment-density` gate fails a file\n  whose added lines exceed 5% standalone comments. Delete commented-out code and stale notes in files you already\n  touch. Preserve\n  `// ignore:`-style directives, license headers, codegen markers, and vendored upstream comments. See\n  [.agents/rules.md](.agents/rules.md) for what belongs in a test or in `.agents/` instead.\n- Start FlClash on the host only as a safe mode build, and end only processes you started, by recorded pid. Never\n  kill a FlClash instance by name; the user's own instance is not yours to close. See\n  [.agents/rules.md](.agents/rules.md).\n- Use `flutter test`, not `dart test`, because models pull in Flutter types.\n- Run code generation after modifying models, providers, or database schema.\n- Do not manually edit generated files.\n- Preserve lifecycle ownership: desktop Core process convergence belongs to `lib/core/desktop/`; Android service intent\n  arbitration belongs to `ServiceState`. UI/provider code may request a transition but must not become a second source of\n  truth.\n- Keep start/stop/restart paths latest-intent-safe. Flutter-to-Android service commands are deliberately optimistic, while\n  native state serializes the actual work; desktop lifecycle results distinguish applied, coalesced, and superseded\n  requests.\n- Never add a `Co-authored-by` trailer crediting a coding agent to a commit, even when your own tooling tells you to.\n  The `commit-msg` hook rejects it; see [.agents/rules.md](.agents/rules.md) for the rest of the commit rules.\n- Round every corner with a superellipse, pills included: `AppShape`/`AppRadius` tokens from `lib/ui/shape.dart`,\n  never `StadiumBorder`, `RoundedRectangleBorder`, `ClipRRect`, `drawRRect`, or a `borderRadius` on `BoxDecoration` or\n  `InkWell`. `test/lint/superellipse_corners_test.dart` enforces it; see the Corner Radius section of\n  [.agents/rules.md](.agents/rules.md).\n- Keep the UI isolate within its frame: work that grows with user data and can exceed about 8 ms on the largest\n  expected data runs in an isolate, once its algorithm is linear or cached. See the UI Isolate Work section of\n  [.agents/rules.md](.agents/rules.md).\n- Follow `lint_options.yaml` (included by every `analysis_options.yaml`), especially single quotes, trailing commas, `child:` last, no `print()`, const/final\n  preferences, and declared return types.\n- Before reporting a change done, run `flutter pub get`, `flutter analyze --no-fatal-infos`, and the `flutter test`\n  suites that cover it, as CI does. If a toolchain the build hook needs is missing (Go, and on Linux and Windows\n  cargo), say which check was skipped rather than dropping it silently.\n\n## Repo Skills\n\nUse repo skills from `.agents/skills/` when a task matches their descriptions; `.claude/skills/` symlinks expose the\nsame skills to Claude Code. Current skills cover localization, UI work, core/platform changes, and pre-commit\nstructural quality review.\n",".agents/skills/ui-work/SKILL.md":"---\nname: ui-work\ndescription: 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.\n---\n\n# UI Work\n\n## When To Use\n\nUse this for user-facing Flutter UI changes in `lib/`, including widgets, screens, navigation surfaces, settings rows,\ndialogs, and interaction behavior.\n\n## Conventions\n\n- Reuse the patterns of nearby widgets, providers, notifiers, and helpers before adding an abstraction, and keep to the\n  look the rest of the app has: Material You from `material_ui` as the base, with Apple's interaction conventions\n  (continuous corners, sheets that snap between detents, a floating tab bar) built on Material widgets rather than\n  Cupertino ones. Do not introduce a visual system for one screen or rewrite a layout the request does not need.\n- `.agents/rules.md` is binding here: the lint set (`child:` last, `const`, final locals), the superellipse corner API,\n  and the widget-test facts under Testing Rules (`pumpAndSettle` never returns on `EditorView`, `dispose()` must not\n  await, auto-dispose providers need a container-level hold, platform decisions are passed in as parameters).\n- A component that reads no provider, model or app string goes in `lib/ui/`; one built for an app feature goes in\n  `lib/widgets/`. The UI Layer section of `.agents/architecture.md` says how a `lib/ui` component gets app state.\n- Settings screens are built from the `ConfigToggleItem` family in `lib/widgets/config_item.dart`; the Settings Rows\n  section of `.agents/architecture.md` says when a file-local helper or a named widget is warranted.\n- Every icon lives in `lib/ui/icons/`, never the Material `Icons` font: reuse or add a glyph in `AppGlyphs` and show it\n  with `GlyphIcon`, or with `AnimatedGlyph` where it fills on selection. Glyphs are outlined, except inside a filled\n  button, where they take `fill: 1`. The Icons section of `.agents/architecture.md` covers shape roles, the stroke, the\n  fill animation, and when to fill.\n- Text sizes and weights come from `AppTextStyle` in `lib/ui/typography.dart`, Apple's iOS Dynamic Type scale, never a\n  Material `TextTheme` slot, a bare `fontSize`, or a `fontWeight`; the Typography section of `.agents/rules.md` has the\n  rule.\n- Modals dim what they cover and never blur it: sheets with the Material 3 scrim (`ColorScheme.modalScrim`, `scrim` at\n  32%), alerts with `ColorScheme.alertScrim`, the lighter dim iOS 27 puts behind an alert. Every modal brings its own\n  scrim, including one opened from inside another, so do not make a barrier transparent to keep scrims from stacking.\n- A sheet's size belongs to its route, never to its content or call site: a bottom sheet rests at\n  `SheetProps.detents`, a side sheet is as tall as the window and as wide as `ModalSideSheetRoute` makes it from\n  the window width (`lib/ui/side_sheet.dart`, pinned by `test/widgets/side_sheet_width_test.dart`), and a form sheet\n  is a card `FormSheetRoute` caps in width and height (`lib/ui/form_sheet.dart`). Do not wrap a sheet's content in a\n  fixed width or height to resize one sheet.\n- A form with more than one value opens with `showFormSheet` and lays its values out in `FormRow`s, never as labeled\n  fields in a `CommonDialog`; a prompt for one value stays an alert. Forms in `.agents/architecture.md` has the rule.\n- A modal scrolls only once the window and keyboard leave it no room, and then through one scroll view: `CommonDialog`\n  caps its body at nothing but the space it is given, and a multi-line field in a dialog or sheet grows with its text\n  (`maxLines: null`) rather than scrolling inside a line count. With the keyboard up, a bottom sheet keeps what it\n  shows above the keyboard, rising past its tallest detent as far as a peek below the top (`_top` in\n  `lib/ui/snap_sheet.dart`).\n- The keyboard animates `MediaQuery.viewInsets` every frame. Read MediaQuery by aspect (`paddingOf`, `viewInsetsOf`)\n  rather than `MediaQuery.of`, and wrap the page of a new route type in `KeyboardInsetHold`; Keyboard Insets in\n  `.agents/architecture.md` has the reasons.\n- A page loads what it shows as it mounts, never once its route has settled; what waits out a transition is work\n  that repeats, such as a live feed's refreshes (`RouteMotionHoldMixin`), and the rows past the viewport\n  (`arrivalScrollCacheExtent`). Page Arrival in `.agents/architecture.md` has the rule.\n- A page that waits on its data shows nothing until all of it is ready (`NullStatusSwitcher.isLoading`), and a load\n  that can run long adds its illustration as `loadingStatus`, never a skeleton screen; Page Arrival in\n  `.agents/architecture.md` has the rule.\n- App bar buttons reach `CommonScaffold` as data (`primaryAction`, `iconActions`, `menuItems`) so it can fold them into\n  its overflow menu; App Bar Actions in `.agents/architecture.md` has the rule.\n- A row's secondary actions open from a context menu, never a trailing more button; a list whose rows have one\n  reorders in a sort mode, any other by a long press on the row. Row Actions And Sort Mode in `.agents/architecture.md`\n  has the rule.\n- Quick edit belongs only on a page that edits a whole list or section, or one isolated item; never on one that edits a\n  part of something other settings also write. Quick Edit in `.agents/architecture.md` has the rule.\n- A page or sheet opens on the navigator of the context it comes from, never on the root navigator unless Where A\n  Route Opens in `.agents/architecture.md` allows it.\n- User-facing text goes through ARB; use `localization` when text changes are non-trivial.\n- An asynchronous control keeps four things apart: authoritative provider/domain state; display-only state such as a\n  minimum progress duration; tap policy while work or a display hold is active; and failure/disposal cleanup, in\n  `finally` for animations and timers. `CoreStatusButton` and the proxies delay test action are the reference implementations,\n  described under User-Facing Core And Delay Feedback in `.agents/architecture.md`.\n\n## Corner Radii\n\nAll corner radii come from `lib/ui/shape.dart`: a token, a derived value, or a named off-scale value passed to its\nbuilders. Never write a bare radius literal at a call site.\n\nThe scale is picked by the component's **shortest side**. A radius that reads as a soft card at 64 dp tall\nreads as a pill at 24 and as a square at 400, so one radius everywhere is the wrong kind of consistency.\n\n| Token | Value | Shortest side | Use |\n| --- | --- | --- | --- |\n| `none` | 0 | - | square edges, and the flat side of a grouped run |\n| `xs` | 4 | inset blocks | tiles clipped inside an already rounded surface |\n| `sm` | 8 | up to 48 | chips, thumbs, swatches, small square tiles |\n| `md` | 16 | up to 64 | interactive chrome: inputs, menus, buttons, popups |\n| `lg` | 20 | 64 to 180 | mid-size cards: dashboard tiles, proxy node cards |\n| `xl` | 24 | full-bleed | anything spanning the whole width: list rows, grouped runs, group headers |\n| `xxl` | 28 | over 200 | sheets, full-screen containers |\n| `alert` | 34 | - | alerts, the corner of iOS 27's UIAlertController; on `AppCorner` only, passed to the builders |\n| `full` | 1000 | - | pills and circles: buttons, tracks, indicators, progress, avatars |\n\n- `AppCorner` holds the scale as `double`; `AppRadius` mirrors it as `BorderRadius` with `all`, `top`, and `vertical`\n  builders; `AppShape` mirrors it as `RoundedSuperellipseBorder`, `full` included, plus `circle`, `input`, and the\n  `all`/`top`/`vertical`/`of` builders. Use `AppShape.full` for pills, never `StadiumBorder`; the Corner Radius\n  section of `.agents/rules.md` explains why and lists the superellipse API per layer.\n- `AppCorner.fit(shortestSide)` snaps to the largest token at or under one third of the shortest side. Use it whenever\n  the size comes from a `LayoutBuilder` or scales with text.\n- `ThemeData.withAppShapes` in `lib/ui/shape.dart` already applies the scale to card, dialog, bottom sheet, snack\n  bar, chip, menu, input, FAB, navigation indicator, progress, and the filled, text, outlined, elevated, and icon\n  button themes. Do not restate those shapes at call sites, and\n  leave `InputDecoration.border` unset so inputs inherit `AppShape.input`.\n- Colors of surfaces follow `.agents/surfaces.md`: fills by `SurfaceLevel`, no structural borders, and a selected\n  item keeping its fill under a `SelectionCheck` or `SelectionBadge`, with no container role on a card or row.\n\n`xl` and `lg` exist because one token cannot serve both size classes. A full-bleed surface is wide and short, so its\ncorner must be larger than its height alone suggests or it stops reading as a card; everything full-bleed shares `xl`\n(the profiles card, the outer corners of a grouped run in `DecorationListItem`, the proxy\ngroup card in `lib/views/proxies/list.dart`) and does not use `fit()`. This is the one deliberate overshoot of the\none-third rule; the value stays under half a standard row so nothing clamps and every full-bleed surface renders the\nsame radius. `lg` is for mid-size cards that are not full-bleed: a proxy node card in the tab view at\n`ProxyCardType.compact` is only about 64 tall, which caps its radius at 21, so `xl` would break on a setting the user\ncan change at any time while `lg` clears both heights and the smallest dashboard tile.\n\nOutlined inputs use `AppInputBorder`, not `ShapedInputBorder` from `package:material_ui`. The package border subtracts\nthe floating label's notch as a flat band along the top edge, which takes the corner curve with it at any radius from 8\nup. `AppInputBorder` clips the notch region and paints the full superellipse through it, as Flutter's own\n`OutlineInputBorder` does by shortening the arc. It needs no `contentPadding` compensation.\n\nNested radii are derived, never tokens: concentric corners need `outer = inner + inset`, so name the inset and compute\nthe outer value, as `_cardRadius` in `lib/ui/popup.dart` does. Adding a token for one of these is how a scale grows without\nbound. A card that also stands on its own elsewhere keeps its token when nested: the proxy node cards inside a proxies\nlist group stay `lg` like those in the tab view, since deriving them from the group card's `xl` left them at 14, smaller\nthan every other card in the app.\n\nA component the scale does not fit keeps its own value instead of growing the scale: name it once in the file and pass\nit to the `all`/`top`/`vertical` builders, as `_itemShape` in `lib/ui/sidebar.dart` does with 10, between the\nchip-sized `sm` and the near-pill `md` on a 40 tall row. Reach for this only once the table and `fit()` are ruled out.\n\n## Verification\n\n- A behavior change gets a focused widget test covering rendering states, taps, scrolling, and empty/error states,\n  including the exception path of any async action. Run `flutter analyze` and `flutter test test/widgets/` plus the\n  tests of the touched view.\n- The scans in `test/lint/` fail on the common UI mistakes and run as part of `flutter test`: an `IconButton` whose\n  icon is an icon needs a `tooltip`; a field that constructs its own controller, notifier, or focus node is released in\n  the same file; a public declaration nothing references is dead. `.agents/rules.md` explains each rule and its\n  allowlist.\n\n## Pitfalls\n\n- Do not mutate provider/domain state merely to smooth a transition. Keep presentation holds local and let real errors\n  bypass them immediately.\n- Do not leave loading animations active when callbacks throw.\n- Do not manually edit generated localization or provider files.\n",".agents/architecture.md":"# Architecture\n\n## Core Integration\n\nThe Go proxy core in `core/` operates in two modes.\n\nAndroid lib mode:\n\n- `gomobile bind` builds `core/mobile` into an AAR: a native library per ABI, shipped as `libclash.so`, and the Java\n  classes gobind generates in `com.follow.clash.core.mobile`. The `:core` Gradle module builds it and takes in its\n  classes and libraries (see Android Core Build).\n- The Android `:core` module owns the binding: `Core.kt` is the only caller of the generated `Mobile` class. Flutter\n  crosses MethodChannels through `lib/plugins/service.dart` and Android's `ServicePlugin` rather than calling the Core\n  directly: `${packageName}/service` for lifecycle commands and Core events, `${packageName}/core` for Core method\n  calls.\n- Flutter runs the UI isolate on Android's main thread (3.47 merges the two and ignores the opt-out in release), so\n  main-thread work anywhere in the process, the Core's included, costs frames. Only sending to Dart needs that thread;\n  a `MethodChannel.Result` may be answered from any thread. `${packageName}/core` is handled on a serial background\n  task queue and answered from the Core's goroutine, an event batch is encoded on the Core's thread and only sent on\n  the main one, `ForceGC` (called from `onLowMemory`/`onTrimMemory`) collects on its own goroutine, and\n  `NetworkObserver` refreshes DNS on its own looper.\n- `lib/core/lib.dart` (`CoreLib`) implements the shared Core interface, gates method calls on its connection completer,\n  initializes and synchronizes Android shared state, and closes the native service path exactly once.\n- Because Core is in the application process on Android, application RSS already includes Core memory.\n\nDesktop core mode:\n\n- Go core runs as a separate process with `CGO_ENABLED=0`.\n- `rust_api`'s `ipc` module provides the native local-IPC primitives: a Unix domain socket on macOS/Linux and a named pipe\n  on Windows, served by an `IpcServer` handle. Dart owns the transport state, RPC correlation, process ownership, and\n  lifecycle convergence above those primitives.\n- `lib/core/service.dart` (`CoreService`) is the composition root. It wires the IPC transport, launcher selection,\n  lifecycle controller, RPC client, and crash-event bridge, and holds no lifecycle logic of its own.\n- `lib/core/desktop/transport.dart` binds an `IpcServer` and turns its events into ready, connected, disconnected, and\n  failed transport events plus a frame stream. A connection that ends on an I/O error is a disconnect carrying that\n  error; failed means the server itself is gone and is terminal for that transport. A replaceable binding keeps RPC\n  subscriptions stable when a failed or stale transport must be rebuilt.\n- `lib/core/desktop/rpc_client.dart` owns request IDs and pending completers, waits up to 10 seconds for a connection,\n  applies a three-minute default method timeout, unwraps `CoreMethodResponse`, and fails all pending calls when transport\n  disconnects or closes.\n- `lib/core/desktop/lifecycle.dart` serializes process intents and owns the authoritative desktop state machine.\n- `lib/core/desktop/launcher.dart` abstracts direct child-process and privileged Helper ownership through idempotent process\n  leases. `lib/core/desktop/helper_client.dart` is the typed local HTTP client for the privileged Helper.\n\n`lib/core/controller.dart` (`CoreController`) selects the implementation based on platform. `lib/core/interface.dart` defines the shared `CoreHandlerInterface`.\n\nKey Go core files:\n\n- `core/protocol.go`: method names, event types, and the parameter and result shapes shared with Dart.\n- `core/method.go`: MethodChannel-style method-call dispatch, the handler table, and response envelopes.\n- `core/lifecycle.go`, `config.go`, `proxy.go`, `delay.go`, `provider.go`, `geo.go`, `connection.go`, `log.go`: the\n  handler functions, one file per area. `core/hooks.go` wires mihomo's hooks to them.\n- `core/message.go`: non-blocking state/priority/bulk event queues and bounded message batching.\n- `core/lib.go`: the Android library API: TUN callbacks, the event listener, and the entry points `core/mobile` binds.\n- `core/mobile/`: the package gomobile binds. gobind generates Java for its exported API, so it speaks only `int32`,\n  `bool`, `string`, `[]byte` and its own interfaces; a Go `int` would become a Java `long`.\n- `core/cmd/core/`: the desktop entry point. `core/` itself is the library package `core`, because gomobile cannot bind\n  a `main` package.\n- `core/server.go`: desktop socket/named-pipe client and framed message forwarding.\n\n## DNS, NTP and Sniffer Overrides\n\nA URL or file profile brings its own sections; a custom profile brings none. The app's DNS page\n(`PatchClashConfig.dns` with `dnsOverrideKeys`) is written over the DNS of every URL and file profile, and offers only\n`DnsOverrideKey.normalProfileKeys`: keys that name nothing of a profile's own, because a subscription's groups and rule\nsets change with every update. A custom profile keeps all three sections in `Profile.overrides`, a `ProfileOverrides`\nstored as JSON in the `profiles.overrides` column, with every key available, and NTP and sniffer exist nowhere else.\n`setupState` resolves `SetupState.overrides` by profile type, intersecting the app-wide keys with the normal set, and\n`_makeRealProfileTask` merges whatever it is handed; there is no on/off toggle, a picked key is the override.\n\nEach `*OverrideKey` names one Clash key, with `parent.field` paths for nested maps, and covers every yaml key of the\nmatching mihomo raw struct; `test/models/{dns,ntp,sniffer}_override_test.dart` read `core/Clash.Meta/config/config.go`\nand fail when a core bump adds or drops one. `overrideJson` builds the fragment in model order and skips a key whose\nvalue is empty, so a key left without a value writes nothing: the core refuses an empty `nameserver` outright.\n`mergeDnsOverride` merges one level deep so a partial fallback filter keeps the profile's other filter keys; NTP spreads\nover the profile's flat `ntp` map. For a profile whose `dns.enable` is not true, the task first fills `baselineDns` for\n`baselineDnsOverrideKeys` (`enable`, `enhanced-mode: fake-ip`, `nameserver`), the one thing the core is never without:\nwith DNS off, mihomo answers every TUN-hijacked query with an error. The baseline stays apart from the model defaults,\nwhich carry no preset of the app.\n\nAn added key starts from the spec's `defaults` through its `OverrideField.reset`, and those defaults are mihomo's: a key\nstarts at the value mihomo parses without it, presets included, such as the nameservers, fake-IP range and filter, and\nNTP server of `DefaultRawConfig`. A key mihomo leaves unset, such as `listen` or a sniff protocol's ports, starts empty\nand writes nothing until the user fills it in, and so does the fallback filter's GeoIP code, whose value is left for the\nuser to choose. mihomo sniffs a protocol listed without ports on that protocol's standard port, which the page shows as\nthe default. The tests above pin the defaults against `DefaultRawConfig`. A spec that mixes in `OverrideQuickEdit` gets\nquick edit, which opens `overrideYaml` in an `EditorPage` and rebuilds the key set from the document through\n`applyOverrideYaml`, rejecting keys outside the section and values the model cannot hold.\n\nmihomo upper-cases sniff protocol names and ignores the deprecated `sniffing` list once `sniff` holds anything, so\n`mergeSnifferOverride` drops the profile's entry for an overridden protocol in any case and first carries `sniffing`\ninto `sniff`. A `rule-set:` key of a custom profile's DNS policy counts as a use of that rule provider, so\n`_resolveInjectedProviders` injects it like one a rule names. Safe mode forces `write-to-system` off on whatever `ntp`\nsection reaches the core, because setting the system clock is a host change; `loadConfig` in the Go wrapper also drops\nit on Android, where the seccomp policy kills the process for `settimeofday`.\n\nTUN is the exception to covering the raw struct. The network settings own only `Tun.coreJson` (`enable`, `device`,\n`stack`, and `auto-route` always on), spread last over the profile's section and patched again on every\n`updateConfig`, so `TunOverrideKey` offers only keys outside it that work in FlClash's TUN. Its keys exist app-wide\n(`PatchClashConfig.tunOverride` with `tunOverrideKeys`, listed in the network page's TUN section as `OverrideEntry`\nrows and added from its last row, as a custom group's options are) for every profile, and per custom profile, whose own keys `ProfileOverrides.overAppTun` lays over the app-wide ones. MTU,\ncongestion control, strict route and the route lists are such keys on desktop, so a profile's own values apply unless\npicked; Android's TUN rows keep them in `Tun`, which its VPN service starts from, and a config saved before the move\ncarries values changed from their defaults into picked keys. `bypassPrivateRoute` stays a network setting: the task\nappends the private ranges to whatever `route-exclude-address` results. `dns-hijack` is a key too: the task writes\n`any:53` beneath the overrides, so a profile's own value never applies, a picked key replaces it, and the key starts at\nthat value. A key sing-tun reads on some platforms only is listed only there\n(`_isRead` in `lib/views/config/tun.dart`): strict route on Linux and Windows, GSO and the interface, user ID and port\nfilters on Linux. `udp-timeout` reaches only the gvisor and system stacks and congestion control only mips, which their\ndescriptions say. Route address sets are left out: sing-tun applies them only through `auto-redirect`.\n`test/models/tun_override_test.dart` fails if a key comes to overlap `Tun.coreJson`. Android starts its VPN from\n`core/tun` without the section, so TUN is listed on desktop only. Being a part of the section, the page has no quick\nedit; see Quick Edit.\n\nA custom profile's overview keeps rows for rules, providers and DNS, which every custom profile runs on, and puts the\ndialer proxies, NTP, sniffer and TUN behind a single \"more\" row, which opens a page listing them with their states.\nBoth lists are the same grouped rows, glyph, title and state, ending in a chevron. Another rarely changed section joins\nthe \"more\" list rather than taking a row of its own.\n\nEvery page is an `OverrideView` over an `OverrideSpec` from `lib/views/config/override.dart`. The spec supplies the\nsection's keys, labels and fields; its `OverrideTarget` is where the model and keys live, `PatchOverrideTarget` for the\napp-wide DNS and `ProfileOverrideTarget` for a profile. Another overridable section is a spec, not a copy of a page.\n\nA config from a release that still had the `overrideDns` toggle keeps, through `Config.fromJson`, the normal keys of\nan on toggle and none of an off one. That release had no custom profiles, so its app-wide NTP override is dropped.\n\n## Listener Exposure\n\n`_makeRealProfileTask` in `lib/common/task.dart` writes `mixed-port`, `allow-lan`, and `external-controller` into every\ngenerated profile. With `allow-lan` off, mihomo's `genAddr` binds each listener to `127.0.0.1`; the external controller is\n`ExternalControllerStatus.close` by default and never binds anywhere but loopback. The task also clears\n`external-controller-tls`, `-unix` and `-pipe` in every mode: the unix and pipe controllers answer without a secret, and\nthe unix one unlinks whatever file sits at its path before opening it to everyone, so a profile may not choose them.\nOutside safe mode, profiles retain `listeners`, `tunnels`, `ss-config`, `vmess-config`, `tuic-server`, `iptables`,\n`dns.listen` and `ntp.write-to-system`. Extra inbounds can bypass the app's LAN and authentication settings, and a TUN\nlistener can change routing independently of the app's TUN toggle. These network and clock effects are an accepted\nboundary; they are not treated as arbitrary filesystem modification. The task disables `iptables` when its\n`inbound-interface` contains a space, because mihomo interpolates it into commands and splits their arguments on spaces.\nSafe mode clears these extra inbounds and iptables, closes DNS listening and disables NTP system time writes even after\nlocal overrides. The editor omits disabled alternate controllers and keeps listener completion.\n\nAn open external controller always has a secret, because mihomo serves a controller with an empty one to every local\nuser and, through CORS, to any page the user visits. `PatchClashConfig.secret` is that secret and it belongs to FlClash,\nas the address does: the task writes it over whatever the profile carries, and `UpdateParams.secret` hands the same value\nto a running Core, which restarts the controller when it changes. `ensureControllerSecret` keeps the pair consistent\n(an open controller gets a generated secret, a closed one drops it, so every opening rotates it) and runs where a\nconfig enters the app: the settings toggle, startup, and `writeConfig` for a restored backup. `_effectivePatchConfig`\nreports a controller without a secret as closed, and `_makeRealProfileTask` independently closes it without a secret\nor in safe mode, so a writer that skips `ensureControllerSecret` opens nothing. The settings page shows the secret in\nan editable row, which refuses an empty value.\n\nThe loopback listener takes local connections without authentication by default: mandatory credentials would lock out\nevery external local consumer a proxy client exists for. The binding only guarantees traffic originates on the device;\nit does not isolate programs already running there, so on Android any app holding `INTERNET` can use the proxy and learn\nthe outbound IP (#1934). The opt-in answer is the local authentication setting (`NetworkProps.authentication`), which spans\nfour paths that must stay in sync: `_makeRealProfileTask` writes the credentials into `authentication` and force-clears\n`skip-auth-prefixes` so a profile cannot silently exempt loopback; `UpdateParams.authentication` applies the same list\nto a running core; `FlClashHttpOverrides` sends the credentials with the app's own proxied requests; and `sharedState`\nwithholds the Android VPN system proxy declaration because `ProxyInfo` cannot carry credentials (TUN still captures that\ntraffic). Desktop system proxy is deliberately not gated — dropping it would leak traffic direct, so the setting's\ndescription tells users to supply credentials manually instead.\n\n### Local Trust Boundary\n\nThe Windows Helper answers only a connection whose client process runs under the owner SID it was installed for;\nthe Linux Helper only the owner uid. A setuid Core is executable by `root:admin` (macOS) or the installing user's primary\ngroup, so its boundary also includes every member of that group, not just the installing account. Profiles cannot choose\na controller address, a secret or alternate controllers. Another account outside those permissions, a subscription,\nor a remote party reaching the privileged Core's control or filesystem APIs is a vulnerability.\n\nA process running as the same account is not stopped. It can ask the Helper to start the Core\nagainst a socket or pipe it owns, or run a setuid Core against a port it listens on, and so become the host that drives\na privileged Core: `initClash` chooses the home directory every path check anchors on, and `getConfig`,\n`getProfileBase`, `getProfileOutbounds` and a `setupConfig` or `buildProfile` base read any path.\nThe Core connects to whatever address it is given and cannot tell that host from FlClash. An account check cannot\ndistinguish it from a same-account process that can already read FlClash's memory and files. This remains a user-to-root\nor user-to-SYSTEM privilege escalation, not a complete security fix. An open external\ncontroller is inside the same boundary: its secret is stored under the account, and `/upgrade` stays mounted (it fetches\nfixed MetaCubeX release URLs, not caller-chosen code). mihomo's `IsSafePath` compares paths lexically, so a symlink inside\nthe home directory escapes it; that fix belongs upstream, not in the submodule.\n\nClosing the same-account case requires limiting what a hijacked Core can do. Keeping the main Core unprivileged and\nmoving host changes into narrow platform helpers requires architectural work, but need not introduce repeated prompts\nor remove ordinary proxy functionality. Features that require additional privileges, such as identifying other users'\nprocesses, need platform-specific handling. Descriptor-based filesystem confinement can preserve ordinary managed\npaths; intentional external symlink targets need explicit allowed roots. These changes have not been implemented.\nAccount authentication, a private socket directory or a symlink check at configuration time does not remove the\nprivileged filesystem operations available to the host. Changes must not widen access to those operations.\n\n### Interface Name Modes\n\nThe same task also writes `interface-name` per `PatchClashConfig.interfaceNameMode`: `clear` (default) forces it empty so a\nsubscription's value cannot survive into the generated profile, `follow` leaves whatever the profile YAML already has, and\n`custom` writes the user's `interfaceName`. mihomo ignores `interface-name` on Android whenever `dialer.DefaultSocketHook`\nis installed (`core/lib.go` `installHooks`, vendored `dialer.go`), so `NetworkListView` in `lib/views/config/network.dart`\nshows the mode picker and text field only on desktop.\n\n### Network Settings\n\n`Tun` holds what Android's VPN service starts from: the switch, device and stack, `mtu`, the mips stack's\n`congestion-controller` and the route lists. The desktop TUN takes only `Tun.coreJson` from it, written over the\nprofile's `tun` section and patched on every update through `tunSchema`; MTU, congestion control, strict route and the\nroute lists reach it as TUN override keys (see DNS, NTP and Sniffer Overrides). `test/models/tun_test.dart` keeps every\n`Tun` key within mihomo's `RawTun` and `coreJson` equal to the keys of `tunSchema`.\n\n`NetworkProps.bypassPrivateRoute` adds `privateRouteAddress` to the excluded routes, through `TunExt.excludedRoutes` on\nAndroid and `_makeRealProfileTask` on desktop. VpnService can only add routes before Android 13, so\n`Tun.vpnRouteAddress` cuts the exclusions out of the routes with `subtractCidrs` and lists every family it routes;\n`VpnService.addRoutes` routes everything only for an empty list. A config saved with the old `routeMode` keeps its\nchoice: `bypassPrivate`, which ignored the route list, loads with the flag on and the list cleared, and `config` with\nthe flag off. No stack in the vendored sing-tun reads `endpoint-independent-nat`, so it is not offered. Android builds\nits TUN from the VpnService descriptor in `core/tun/tun.go`, so only MTU and the congestion controller reach it,\nthrough `VpnOptions` and the `options` argument of `startTun`; the VpnService builder takes the same MTU.\n\nThe TUN fields start at mihomo's own defaults, and a saved zero MTU or empty congestion controller, which mihomo reads\nas those defaults, decodes to them. The outbound rows are plain settings written over every profile, and changing one\nre-applies the profile; keep-alive is forced off on Android, and only Linux honours `routing-mark`.\n\n## Lifecycle Ownership And Convergence\n\n### Shared Flutter Layer\n\n`CoreController.start()`, `restart()`, `stop()`, and `close()` are the only shared lifecycle facade. `close()` is terminal;\ncallers must not try to reuse a closed platform implementation.\n\n`CoreAction` in `lib/providers/actions/core.dart` owns the user-facing Core status and setup sequence:\n\n- `startCore()` publishes `connecting`, starts the platform Core, publishes `connected`, then initializes Core state. A\n  startup error publishes `disconnected` and displays the error.\n- `restartCore()` coalesces overlapping callers behind one worker. `_requestedRestartRevision` records newer requests.\n  After the lifecycle restart, the worker reapplies running state or the profile, rereading `isStartProvider` on every\n  pass, until it has consumed the latest revision, so the newest start intent wins.\n- The provider is an orchestration and presentation layer, not a process owner. Platform lifecycle code remains responsible\n  for determining whether a Core process/service is actually running.\n\nApplication exit is centralized in `SystemAction` and `SystemExitCoordinator`:\n\n1. Optionally save config and clean up DNS, system proxy, and tray resources in parallel.\n2. Close the desktop window.\n3. Call terminal `CoreController.close()`.\n4. Exit the application exactly once.\n\nThe coordinator is idempotent, continues later cleanup steps after an earlier error, preserves the first error for the\ncaller, and uses a three-second watchdog as an emergency application-exit path. `Application.dispose()` and\n`CoreManager.onCrash()` do not independently destroy Core; this avoids competing shutdown owners.\n\n### Applying Setting Changes\n\nA changed setting is patched live through `UpdateParams`, re-applied with the profile, or, for the Android VPN, left\nto a restart the user confirms. [setting-changes.md](setting-changes.md) says which path each setting takes and how to\nchoose one for a new setting.\n\n### Desktop Lifecycle\n\n`DesktopCoreLifecycle` is a latest-desired-intent reconciler, not a queue that blindly executes every request:\n\n- Public intents receive monotonically increasing revisions and target running, restarted, stopped, or closed.\n- Observable phases are `idle`, `starting`, `running`, `stopping`, `failed`, and `closed`.\n- A completed command reports `applied`, `coalesced`, or `superseded`, allowing callers and tests to distinguish a command\n  that won from one satisfied or replaced by a newer compatible intent.\n- Startup opens or replaces the IPC transport, resolves a launcher, generates a 128-bit lowercase hexadecimal session ID,\n  launches Core, and waits for the matching connection. Windows additionally verifies that the named-pipe peer PID equals\n  the process PID returned by the Helper lease.\n- Each running session retains its process owner, lease, PID, session ID, and transport connection generation. Stop waits\n  for both process-exit confirmation and the matching disconnect generation; a missing disconnect replaces the transport\n  before later starts.\n- An unconfirmed process exit is retained as an unconfirmed lease. New start/restart intents fail until ownership can be\n  cleaned up, preventing two Core instances from being treated as the active session. Terminal close may continue on a\n  best-effort basis because the application is exiting.\n- An unexpected disconnect or transport failure while running is converted to `DesktopCoreFailure`, the owned process is\n  cleaned up, and `CoreService` emits a Core crash event for the normal UI recovery path.\n\nDirect launch is used on macOS, inside an AppImage, and as the Windows/Linux fallback when the privileged Helper is not\nready. When the Helper is ready, it owns the Core child and Dart owns it through a session-scoped lease.\n\n### Android Service Lifecycle\n\nAndroid deliberately keeps Flutter requests optimistic and the native layer authoritative:\n\n- `ServicePlugin.start()` and `stop()` acknowledge immediately after submitting intent. They do not wait for service\n  creation, VPN permission, binding, TUN establishment, or teardown.\n- `ServiceState` owns the latest `RunRequest` and the shared configuration. Identity checks discard obsolete work.\n  `startPreparationLock` serializes permission/setup preparation and `transitionLock` serializes actual service\n  transitions.\n- `RunState` is derived, never assigned by a step: `ServiceStateMachine.publish` computes it from the transition in\n  flight, the latest intent, and `ServiceController.runtime`. A service that is up under a stop intent reads `STOPPING`, a\n  start still waiting on a permission reads `STOPPED`, and `STARTED` needs both a running intent and a runtime. The tile\n  and `getRunTime` both read this one value, so they cannot disagree with the service.\n- `ServiceController` owns exactly one `ManagedServiceBinding`, selects `VpnService` or `ProxyService` from `VpnOptions`,\n  binds with a five-second connection timeout, and invokes `ManagedService.start()`/`stop()` off the main thread. The\n  binding and `runtime` (which service runs, and since when) are set and cleared together, by a stop, a failed start, or\n  a lost connection and by nothing else. Flutter's `shutdown` only clears the Core event listener: Android keeps an\n  established `VpnService` alive through its own connection, so a binding dropped on exit left a tunnel that was\n  reported as running but that neither a stop nor a revoke could reach.\n- The Core listens exactly while `runtime` is set: a successful start ends in `Core.startListener()` and every path\n  that clears `runtime` calls `Core.stopListener()`. Both return at once when the Core is already there, which is the\n  usual case, since `startTun`/`stopTun` and Flutter's own `startListener`/`stopListener` got there first. They matter\n  when nothing else does: a proxy-only run stopped from the tile used to keep the mixed port open with nothing\n  reported as running. A start closes after itself the same way: Flutter's `startListener` and the native `quickSetup`\n  both open the ports before any service exists, so a start that ends without a `runtime`, refused or cancelled, stops\n  the empty service once more, without passing through `STOPPING`. Flutter may be gone by then and never see the\n  `stopped` report.\n- A start that does not bring the service up gives its intent back and takes down whatever it left running, unless a\n  newer request has claimed the service. A failed start therefore always ends in `STOPPED`, whichever step refused it.\n  The one start that mints no request is a repeat on a settled run of the same kind, which Flutter sends every time it\n  reattaches: a request refused at a permission prompt there would stop a service that was running fine.\n- Generic service creation/destruction is lifecycle evidence, not user intent. New commands must flow through\n  `ServiceState.requestStart()`/`requestStop()` or the explicit system-action handlers instead of inferring intent from a\n  callback.\n\nQuick Settings, notification, revoke, and Always-on VPN paths converge on the same owner:\n\n- With a Flutter engine attached, `ServiceState.handleStartAction()` forwards through `TilePlugin` to `TileManager`,\n  because only Flutter can apply the current profile. Without Flutter, native code claims the intent, restores\n  `SharedState` from preferences, runs `quickSetup`, checks VPN permission, and runs the start; claiming first is what\n  makes a second action during the setup toggle that start off instead of racing a second setup against it.\n- An engine that attaches to a run the service already has, started that way or outliving the last engine, adopts it:\n  `SetupAction.initStatus` restores the md5 of the config.yaml the Core last accepted and pushes the profile only when it\n  now builds differently. Pushing the same config again rebuilds every proxy group, and mihomo keeps a url-test group's\n  unmeasured pick for ten seconds, so the dashboard's exit and service checks ran a second time once it moved.\n- A stop never needs Flutter. `handleStopAction()` stops the service natively whether or not an engine is attached, so\n  the tile, the notification action and a revoke work even when Flutter's own idea of the state is wrong.\n- Whenever the run ends without Flutter having asked for it (a start that failed, a native stop, a revoke, a lost\n  service), `ServicePlugin` sends `stopped` with the revision of the last `start`/`stop` command it received.\n  `Service` in `lib/plugins/service.dart` drops a report older than its newest command, or one that arrives while its\n  last command was a stop (on-demand suspension stops the service while `isStartProvider` stays true), and\n  `AndroidManager` answers the rest with `setRunning(false)`. The report is read on the platform thread, where the\n  commands arrive, so the revision and the intent it carries describe the same instant.\n- Android may create an Always-on `VpnService` through `onStartCommand()` without FlClash's bound-service path. The service\n  sends the explicit, permission-protected `VPN_START_REQUESTED` broadcast to `ServiceBroadcastReceiver`, which routes it to\n  `ServiceState.handleStartAction()` so Core/configuration and the normal binding are restored before TUN is treated as\n  ready.\n- `VpnService.onRevoke()` stops TUN/modules first, then sends `VPN_REVOKED`; the receiver only requests a stop while\n  `ServiceController.runtime` is a VPN run.\n- `ServiceBroadcastReceiver` uses `goAsync()` and an atomic one-shot completion. Normal completion or a nine-second\n  watchdog calls `PendingResult.finish()` exactly once; the watchdog releases Android's broadcast lease and does not\n  cancel or redefine the underlying lifecycle intent.\n\n## Core Protocol And Event Delivery\n\nThe shared protocol uses `CoreMethodCall(id, method, arguments)` and `CoreMethodResponse(id, result, error)` in both\ndirections. The envelope is the only JSON serialization layer: keep arguments, results, and event data as structured JSON\nvalues rather than embedding pre-encoded JSON strings. Plain domain strings, such as country codes or provider contents,\nremain strings.\n\nA response over 1 MiB goes to a file under `<home>/responses/`, and the envelope carries only its `file`\n(`core/spill.go`). Sent inline, Android copies it into the Java heap three times (the gomobile `byte[]`, the codec and a\ndirect buffer), and desktop IPC drops a frame above `maxIPCFrameSize`. `lib/core/spill.dart` reads a spilled file only\nfrom that directory, decodes it in an isolate and deletes it; `initClash` clears what an earlier run left.\n\nA profile file's proxies, groups and rules never cross the bridge. `getProfileBase` returns the config without\n`proxies`, `proxy-groups`, `sub-rules` and `rules`, with their counts, the profile's MATCH rules and a sha256 digest.\n`SetupAction.getProfile` patches it as before and sends the result as `SetupParams.base.overlay`. `core/profile_base.go`\nputs the profile's bulk sections into the overlay, appends the profile's rules after the overlay's, writes `config.yaml`\nfor the Android service and the controller's reload, and parses it. `config.yaml` is encoded a slice at a time, because on a\nprofile of 100k proxies and 2M rules one `Encode` call doubles the peak heap. The preview reads the YAML `buildProfile`\nmerges into the spill directory, and the extend page only the names\nand types `getProfileOutbounds` lists; a script extension still needs the whole config from `getConfig`.\n\nGo event delivery is intentionally non-blocking:\n\n- Loaded-provider, geo-update, and route-changed events use a state queue that never evicts; the rules for it are under\n  Core API Safety in `.agents/rules.md`. Every other event except the bulk kinds, delay included, uses a 256-entry\n  priority queue. Desktop process crashes are generated locally by `CoreService` from lifecycle failures rather than\n  sent through the Go queue.\n- High-volume log, request/connection, and DNS query events use a separate 256-entry bulk queue, so bulk floods cannot\n  evict state.\n- A full priority or bulk queue evicts only its own oldest event and retries the newest; DNS events never evict. Core\n  work never blocks on event delivery.\n- The batcher flushes at 32 messages or every 16 milliseconds. Priority events are preferred, but one bulk opportunity is\n  guaranteed after eight priority messages to prevent starvation.\n- `sendMessage` drops an event before it is queued when `hasEventListener` is false. That only happens on Android, after\n  the Flutter engine detaches while the service keeps the Core running; request and DNS events would otherwise be\n  encoded per connection and per query for nobody.\n\nDNS query events come from the Clash.Meta fork's `dns/patch.go` and are raised where mihomo writes its `[DNS]` debug\nlog, so the DNS page lists the same resolutions: one event per cache hit, and one per upstream exchange however many\ncallers singleflight merged into it, carrying the upstream whose answer was used. Fake-IP and hosts answers never reach\na resolver and are not recorded. Queries from apps arrive with the `DNSContext` that `Service.ServeMsg` created and\nreport `app`; other initiators come from `resolver.WithInitiator` at the call site (rule matching, DIRECT, proxy\nserver dialing), and unlabeled queries report `other`.\n\nDialer-loop events come from the fork's `component/proxydialer/patch.go`. mihomo follows a `dialer-proxy` that leads\nback to its own proxy until the stack overflows, so a dial that asks the same `dialer-proxy` for an address it is\nalready dialing through it fails with `ErrDialerLoop`. `core/hooks.go` reports each looping name at most once every\nthree seconds as a priority event, which the app shows as an error notice whether or not logs are captured. The custom\nprofile pages flag only the loops FlClash can follow on its own, through a group's own list or an `include-all-proxies`\nthat no filter, exclude filter or exclude type narrows; what filters and providers pick, including provider proxies\nthat carry their own `dialer-proxy`, is left to this guard.\n\n`ProxyGroup.fromJson` reads `include-all` as both `include-all-proxies` and `include-all-providers`, as the core does, so\nimport and quick edit keep it. They take in only what the config declares, which is what some group of the profile\nnames: its local proxies and the file or url profiles in a `use`. Setting one declares nothing more, so the app's other\nproxies and subscriptions stay out, and the core picks from the declared ones by the group's filters. Groups saved by\nv0.9.0 kept `include-all-proxies` in their stored `definition`, and it takes effect again.\n\nDesktop RPC accepts both a single event object and batched event lists. Android and desktop listener dispatch isolate\nlistener exceptions so one faulty observer does not prevent the remaining events/listeners from running.\n\n## User-Facing Core And Delay Feedback\n\n`CoreStatusButton` in `lib/views/dashboard/widgets/core_status_button.dart` is the desktop dashboard's status/restart\nsurface. It is shown only outside dashboard edit mode and only when `coreLib == null`:\n\n- Provider state remains authoritative. The widget keeps a separate display-only status so a fast\n  `connecting -> connected` transition still shows at least 600 milliseconds of progress instead of flashing.\n- The hold arms only after an observed transition to `connecting`; mounting while already connecting does not invent a new\n  delay. A real `disconnected` transition cancels the hold immediately so failure is never hidden, while a long-running\n  connecting state remains visible after the timer expires.\n- Taps during the display hold or while the provider is genuinely connecting are inert. Connected/disconnected taps show\n  the appropriate confirmation and delegate restart to `CoreAction`; the widget never starts Core directly.\n\nProxy delay testing follows the same failure-safe UI rule. `ProxiesAction._runDelayTests` marks its keys in\n`pendingDelayTestsProvider` and releases them in `finally`, and a test the Core does not answer leaves the last\nmeasurement in place; the delay-test bullets under Core API Safety in `.agents/rules.md` give the reasons. A key stays\n`queued` until the `TaskPool` actually runs it, so node cards animate a loader for at most `maxConcurrentDelayTests`\nnodes and show a static glyph for the rest; a loader on every queued node made fast scrolling drop frames. A group's delay\ntest button, the tab page's action and the list page's group header alike, spins while `delayTestingGroupsProvider`\nholds the group; `delayTestPageGroup` marks it, ignores a second request for the same group, and clears it in `finally`,\nso an RPC failure cannot leave the button spinning and remounting a header does not lose the state.\n\nThe dashboard's probe cards show a failure as still checking for five seconds after they see `proxied` turn true,\nthrough `ProbeStartHold` in `lib/views/dashboard/probe_start_hold.dart`: `ServiceStatusCard` holds the check on each\ntile, and `CurrentOutboundCard` holds the exit IP, whose refresh button stays inert meanwhile.\n`SetupAction.setRunning` flips the run time, and with it `proxied`, before the Core has brought its listeners up, so the\nprobe launched on the spot often fails, and the config re-apply that follows every start probes again moments later;\nwithout the hold a card flashes a failure and then takes it back. The hold is local display state like the connecting\nhold above: the cache still records the failure, the hold arms only on an observed transition, a result that is not a\nfailure shows at once, a stop cancels it, and a failure still standing when it ends is shown.\n\n## Route Consistency\n\nA probe result on the dashboard — the exit IP, a node's exit IP, a service check — either describes the route in\nforce or is being asked again. The mechanism has three parts.\n\n- The Core is the source of truth for the route. `core/route.go` keeps two counters. The epoch is a reset, which\n  no answer outlives: a config apply, an IPv6 switch, and a start (the listeners coming up and, on Android, the TUN\n  after them). The route version is a move, which may send a host to another exit: a Selector, URLTest or Fallback\n  pick changing, a proxy provider whose version changed, a rule set that loaded new content, a GeoIP or GeoSite\n  update, and a mode switch. `handleChangeProxy` re-reads the picks under `selectMu` and answers\n  `ChangeProxyResult{changed}`, so the connection reset only runs when the pick moved. A health check moving a\n  URLTest pick has no mihomo hook, so the picks are polled every second while the host watches (`watchRoute(true)`)\n  and the listeners are up; the same poll notices a provider or rule set that updated in the background, which in\n  this fork includes every provider an apply loads, since `loadProvider` returns before they finish. Every probe answer\n  carries the counters it started under and its hits: each host it dialed, the proxy it was pinned to, and the exit\n  it left through, read off the tracked chain or, for a connection that never reached the tracker, matched again.\n  An exit is the last hop with its server address, or a load balancer, which spreads connections without the route\n  moving. `checkRoutes` matches those hosts again through `tunnel.ResolveRoute`, a fork patch that runs the rules\n  without dialing, and reports per answer whether every exit held. `routeChanged` publishes the counters whenever\n  they differ from the last read. A start resets the epoch because `isStartProvider` flips before the Core is told,\n  so the probes sent at the tap leave ahead of the TUN routes and over DNS connections `handleStartListener` resets,\n  and an Android start acknowledges before the VPN exists. Outbound IP lookups are cancelled by the first lookup\n  under a newer epoch, which frees their probe slots; lookups under one epoch are left alone, since the routed\n  target while stopped and a pinned `DIRECT` send the same request.\n- `routeTrackerProvider` in `lib/providers/route_state.dart` merges that with the host's side: `proxied` (running and\n  not suspended), a host epoch that a connectivity event moves, and `synced`, which is false until `watchRoute` has\n  returned a snapshot and again after a Core disconnect. Nothing is fresh while unsynced, so a cache never judges\n  answers against counters it made up. The tracker subscribes when the first target is watched and releases the Core\n  when the last one goes; an answer newer than the state means an event was evicted, and the cache asks for a resync.\n  It also releases the Core while the app is hidden and reads it again on return, keeping `synced`: the Core and its\n  counters are the same ones, so the fresh snapshot invalidates only what moved. On Android the Core drops the watch\n  itself when the event listener is cleared, because an engine that goes away never sends `watchRoute(false)`.\n- `RoutedProbe` in `lib/providers/routed_probe.dart` is the one cache both `OutboundIpProbe` and `ServiceStatus` mix\n  in. A widget `watch`es the target it shows and `unwatch`es on the way out; only watched targets are probed, one\n  request in flight per target. An answer is stamped with the Core's counters, its hits and the host state at\n  launch. A reset (`RouteState.hasMovedSince`: the Core epoch, the host epoch or `proxied`) invalidates everything:\n  a stale watched entry shows as checking at once and is asked again after `probeMoveSettleDuration`, since a start\n  moves it three times within about 150 ms (`proxied`, then the listeners, then the Android TUN) and the user is\n  watching for the new address. A new route version keeps every answer on show and, once `probeSettleDuration` has\n  passed the next pick poll, sends their hits to `checkRoutes` in one call; only an answer whose exit moved is\n  dropped and asked again, and one that held takes the checked version. An answer that lands under an older version\n  than the state is held back as checking until its own check returns, so it never shows and then flips. An ask\n  that is not the user's or a reset's waits out `probeThrottleDuration` since the target's last probe, so a config\n  load whose providers, rule sets and url-test picks land over a few seconds moves an exit at most one probe more.\n  A `refresh` waits for neither, and an unwatched entry whose exit moved is dropped. Nothing launches or checks\n  while the app is hidden: what falls due waits for it to be shown.\n  Failures wait for `retry`, `refresh`, or the app resuming (`markResumed`), and\n  a service check that timed out is a failure that still shows its status. `refresh` is the user's own request and\n  runs for unwatched targets too: the service sheet reads the cache and never watches its rows, because one service\n  check is one to four full HTTPS fetches where a delay test is a single empty request.\n\n`ipQuality(ip)` in `lib/providers/ip_quality.dart` is keyed by the outbound IP found, so a route change reaches it as\na new IP and an unchanged IP reuses the result. A lookup races its sources the way the IP check does and takes the\nfirst one that determines a type. A success is saved to the `IpQualities` table and answers for that IP, across\nrestarts, until `ipQualityMaxAge` (three days) has passed; a shown result is asked again when it expires,\nand invalidating the provider (the sheet's check again) skips the saved record. A failure is not saved, and Riverpod's\nautomatic retry is off because a retry after an HTTP 429 spends the same per-outbound-IP quota again.\n\n## UI Layer\n\n`lib/ui/` holds the components and design tokens that know nothing of the app: shapes, surfaces, motion, colors, the\nscaffold, sheets, popups, lists and the glyphs under `lib/ui/icons/`. `lib/widgets/` holds the components built from\nthem that do: settings rows, subscription and profile pieces, anything that reads a provider or a model.\n`widgets/widgets.dart` re-exports `ui/ui.dart`, so app code imports either barrel.\n\n`test/lint/ui_layering_test.dart` walks the imports of every file under `lib/ui`. Nothing they reach may leave\n`lib/ui`, except `lib/common` files whose own imports stay out of the app, never the `common.dart` barrel, and none\nmay import Riverpod. A component that needs app state reads what the app provides above its navigator:\n\n- `ViewScope` (`lib/ui/view_scope.dart`): the window size, whether the layout is mobile, and the window controls\n  over a page. `AppViewScope` in `lib/manager/theme_manager.dart` fills it from `viewSizeProvider`,\n  `isMobileViewProvider` and `versionProvider`. It is an `InheritedModel`, so a reader of one value does not rebuild\n  when another changes; `context.isMobileView` reads it without listening.\n- `MeasureScope` (`lib/ui/measure.dart`): the `Measure` that `ThemeManager` also assigns to `globalState.measure`.\n- The `MediaQuery` text scaler that `ThemeManager` sets: the app text scale behind `.ap(context)`, `.mAp(context)` and\n  the `...Of(context)` paddings in `lib/ui/metrics.dart`. Reading it there makes a widget sized by them rebuild when the\n  scale changes; a global copy or a top-level final would keep the scale it was first read at.\n- `UiLocalizations` (`lib/ui/l10n.dart`): the strings the components show themselves. `AppUiLocalizations.delegate`\n  in `lib/common/app_localizations.dart` serves them from the ARB strings; a new one needs a getter on both.\n\nA test that builds its own `MaterialApp` registers `AppUiLocalizations.delegate` and returns `wrapAppScopes` from\n`test/helpers/test_app.dart` in its builder, as `TestApp` does.\n\n## Icons\n\n`lib/ui/icons/` holds every icon the app shows. Nothing in `lib/` uses the Material icon font, and\n`test/lint/material_icons_test.dart` fails on any `Icons.*` or `AnimatedIcons.*`, so every icon shares one stroke and\ncan animate its shape.\nA `Glyph` is a list of shapes on a 24-unit grid, stroked at `Glyph.strokeWidth` with round caps and joins and\nsuperellipse corners; a curve the primitive shapes cannot draw is a `GlyphPath`, SVG path data traced along the\nstroke's center. Each shape has a role that decides its filled form, the way SF Symbols and Fluent pair an\noutlined icon with its filled variant:\n\n- `body` fills;\n- `detail` is cut out of the fill, and a `solid` detail such as a dot is cut as an area;\n- `line` stays a stroke, for parts that belong to no body, like a handle or a link between nodes; it thickens from\n  `Glyph.strokeWidth` toward 2.2 as the glyph fills, so a glyph of lines alone still carries weight in a filled button.\n  A `solid` line, such as the dots of `AppGlyphs.more`, scales by the same ratio.\n\n`GlyphPainter` paints any point between the two forms. Each body fills inward from its outline, reaching half its\nshorter side, which no point of the body lies beyond, as `fill` reaches 1. Details thin away over the first half and\nreturn as cutouts over the second, so a detail is never a stroke and a cutout at once. The glyph is painted opaque into\none layer that carries the color's alpha: a translucent color, such as a disabled one, then stays even where strokes\ncross the fill, and a cutout clears only the glyph's own pixels. `GlyphIcon` shows a glyph at a fixed `fill`, taking\nits size, color, and fill from `IconTheme` unless it is given its own, as `Icon` does, and mirroring it in right-to-left\ntext when `Glyph.matchTextDirection` is set.\n`AnimatedGlyph` drives a `GlyphIcon` from its `filled` flag and pops briefly when it fills. A glyph whose shape follows\nsome other state, like the sidebar toggle that tracks the pane's width, is a builder in `AppGlyphs` such as\n`AppGlyphs.sidebar(progress)`. A new glyph goes in `AppGlyphs` and in the list `test/icons/glyph_painter_test.dart` runs\nthrough its checks; it is named for what it means in the app, one glyph per meaning, with SF Symbols and Fluent as the\nreference for the metaphor. Tests find one with `find.byGlyph` from `test/helpers/glyph_finders.dart`.\n\nIcons are outlined by default and filled inside a filled button: `IconButton.filled` and `.filledTonal`, and\n`FilledButton` in its tonal and icon forms draw their glyph with `fill: 1`, because an outline on a tinted\ncontainer reads weaker than the container it sits in. A glyph with no body only thickens its lines and still takes\n`fill: 1`, so the rule holds if the glyph later gains one. `test/lint/filled_button_glyphs_test.dart` checks every\n`GlyphIcon` written inside such a button; an icon that reaches the button from elsewhere, as through a helper that picks\nthe button variant, gets its fill by hand or from `IconThemeData.fill`. Everywhere else fill marks state only: the\nselected destination, a pinned window, a lit torch. The phone's floating navigation bar is the one exception: like an iOS\ntab bar it fills every destination and marks the selected one by colour alone.\n\nFramework widgets that draw their own icon take it from the theme: the `ActionIconThemeData` in `lib/application.dart`\nsupplies the back and close buttons, with `AppGlyphs.backFor` picking the platform's back glyph, and a `DropdownMenu`\nis given its chevrons. A framework widget added later that paints a Material icon needs the same treatment.\n\nEmpty states draw no image assets either. `NullStatus` in `lib/ui/null_status.dart` shows the glyph for its\nmeaning filled on a Material 3 Expressive shape from `material_new_shapes`, with a few accent marks, and enters with\none of three choreographies picked at random: the shape spins in, morphs out of a circle, or springs in while the\naccents orbit into place. The glyph takes `onPrimaryContainer`, not `primary`: the `content` scheme variant can leave\n`primaryContainer` as light as `primary` in dark mode.\n\n`CaptionIcon` is the one icon that does not follow the glyph grid: the window caption buttons keep the one pixel,\npixel-aligned stroke of Windows' 10-pixel caption glyphs on every platform.\n\n## App Bar Actions\n\n`CommonScaffold` keeps at most three buttons in sight in its bar, the overflow menu button included. A page hands it\nwhat may fold as data: `primaryAction`, `iconActions`, `menuItems`, and `searchActions` while searching; custom widgets\npassed through `actions` never fold. Past three, two buttons stay beside the overflow menu: the search button when\nthere is one, then foldable actions, primary action first;\neverything else goes into one overflow menu, primary action first, and a folded action that is loading shows as a\ndisabled item. The bar reads as one button group of two beside a single button: the overflow menu button when\nsomething folds, otherwise the last action, so the proxies list shows a search and delay test group beside the menu.\nThe bar holds `primaryAction` everywhere, a phone's home page above the fixed navigation bar included, and nothing in\nthe app floats an action button: a secondary a",".agents/rules.md":"# Rules\n\nThese are repository coding and testing conventions. Codex command permission rules belong in `.codex/rules/*.rules`; see `.agents/agent-config.md` before adding those.\n\n## Dart and Flutter Style\n\nThe lint set lives in `lint_options.yaml` at the repo root. The root `analysis_options.yaml` and every local plugin under\n`plugins/*` include it, so application and plugin code are held to the same rules. Add or change a rule there, not in an\nindividual `analysis_options.yaml`; those files carry only their own `analyzer.exclude` entries.\n\n`lint_options.yaml` enforces these non-default rules:\n\n- `prefer_single_quotes: true`: always use single quotes.\n- `require_trailing_commas: true`: use trailing commas in multi-line argument lists.\n- `sort_child_properties_last: true`: `child:` must be the last named parameter.\n- `avoid_print: true`: do not use `print()` calls.\n- `prefer_const_constructors: true` and `prefer_const_declarations: true`.\n- `prefer_final_locals: true` and `prefer_final_in_for_each: true`.\n- `always_declare_return_types: true`.\n- `only_throw_errors: true`: throw an `Exception` or `Error`, never a bare `String`.\n\nFailures whose whole content is a message meant for the user throw\n`MessageException` from `lib/common/exception.dart`. Its `toString()` is the bare\nmessage, which is what `globalState.safeRun` surfaces in the dialog, and the\nthrow stays catchable as an `Exception` and carries a stack trace. Assert on it\nwith `isA<MessageException>().having((e) => e.message, 'message', ...)`, not on a\nraw string.\n\n### Corner Radius\n\nRounded corners are superellipses (continuous corners) everywhere, never circular arcs; when a component offers\nboth, pick the superellipse. `test/lint/superellipse_corners_test.dart` fails on the circular APIs below in `lib/`. Use\nthe superellipse API at each layer:\n\n- Shapes: `RoundedSuperellipseBorder` instead of `RoundedRectangleBorder`.\n- Clips: `ClipRSuperellipse` instead of `ClipRRect`.\n- Container decorations: `ShapeDecoration(shape: RoundedSuperellipseBorder(...))` instead of\n  `BoxDecoration(borderRadius: ...)`; borders move to the shape's `side`, and a `Container` with\n  `clipBehavior` still clips to the shape path.\n- Canvas: `canvas.drawRSuperellipse(RSuperellipse.fromRectAndRadius(...))` instead of `drawRRect`.\n- Ink: `InkWell.customBorder` with an `AppShape` instead of `InkWell.borderRadius`.\n\nPassing `BorderRadius.circular(x)` as the `borderRadius` argument of these APIs is expected — it only\ncarries the corner magnitude; the rendered geometry stays a superellipse.\n\nPills are no exception. `StadiumBorder` joins its round caps to the straight sides with a curvature jump, while\n`AppShape.full`, a superellipse at full radius, blends them, and on a square it renders the exact circle, so it serves\ncircles too. Material 3 defaults every button, icon button, segmented button, and search bar to `StadiumBorder`;\n`ThemeData.withAppShapes` replaces that for the button themes, and a local `FilledButtonTheme` or `IconButtonTheme`\nreplaces the app's, so merge the ambient `XButtonTheme.of(context).style` into it, as `CommonMinFilledButtonTheme`\ndoes. `CircleBorder` and `BoxShape.circle` are fine for something that is always square.\n\nAPIs that accept only a `Radius`, such as `ScrollbarThemeData.radius`, keep circular corners; supply the superellipse\nwith an enclosing clip or shape where it shows. Framework widgets that paint their own circular corners with no shape or\ndecoration to override are left as Material draws them; switches are `CommonSwitch`, drawn by the app. Tooltips take\ntheirs from `withAppShapes`, which sets `tooltipTheme.decoration`.\n\n### Typography\n\nText sizes follow Apple's iOS Dynamic Type scale at its default size, on every platform. `AppTextStyle` in\n`lib/ui/typography.dart` holds its eleven styles, `largeTitle` to `caption2`, with Apple's leading and weights, plus two\nsteps the scale lacks: `display` for a dashboard figure and `pageTitle` for an app bar. Take one with\n`AppTextStyle.body.of(context)`, pass `emphasized: true` for the scale's emphasized weight (bold for the titles,\nsemibold for the rest), and change color or family with `copyWith`; a weight is either regular or emphasized. Text\nsized to the shape it marks, such as a letter scaled to its icon, comes from `AppTextStyle.custom`. The dashboard's\nroles (`DashboardText` in `lib/views/dashboard/style.dart`) are built on the same types. `ThemeData.withAppTypography`\nmaps Material's `TextTheme` slots onto the scale so Material components follow it; app code never reads those slots.\n\nApp bar titles sit at the leading edge in `pageTitle` on a root page and on any desktop page, at the leading edge in\n`headline` on a pushed page in the mobile view, and in the bar's centre in `headline` on a bottom sheet. A title widget,\nsuch as the editor's name field, stays at the leading edge. Past a back button a title keeps the bar's action inset\nclear of the button on either side.\n\n`test/lint/typography_test.dart` fails on `textTheme` anywhere in `lib/` but `typography.dart`, and on `fontSize:` or\n`fontWeight:` outside `lib/ui/`, except in the code editor, whose text follows the editor font-size setting.\n\n### Text Field Suggestions\n\nOn Android, Flutter maps `enableSuggestions: false` to the visible-password input type, not only to \"no suggestions\"\n(`TextInputPlugin.inputTypeFromTextInputType` in the engine). Input methods then treat the field as a password: no\nclipboard panel, no Chinese layouts, and on some systems a secure keyboard. With the app's clipboard permission denied,\nthat leaves no way to paste a link at all. Only the secret fields, `SecretTextFormField` and `FormRowSecretTextField`,\nask for a password keyboard; a link, key, rule or regex field that should not be corrected sets `autocorrect: false`\nalone. `test/lint/text_field_suggestions_test.dart` fails on `enableSuggestions: false` or\n`TextInputType.visiblePassword` anywhere else in `lib/`.\n\n### Length Units\n\nFlutter lengths are logical pixels, the unit Android calls dp. Comments, test names, docs and reports give them in dp,\nas in `48 dp`, never px: a reader takes px for physical pixels and misjudges the size by the device pixel ratio. Write\n\"physical pixel\" in full where the code really works in them, as the hairline snapping in\n`lib/ui/list_row_separator.dart` and the Win32 frame metrics in `plugins/window` do; raster image sizes in\n`tool/` and CSS under `site/` keep px. `test/lint/length_unit_test.dart` fails on a number followed by px in `lib/`\nand `test/`.\n\nCI gates formatting: `dart format --output=none --set-exit-if-changed lib test\ntool plugins setup.dart` runs before `flutter analyze`.\n\nThese directories are excluded from analysis:\n\n- `build/**`\n- `lib/l10n/intl/**`\n- `lib/**/generated/**`\n- `plugins/**`\n- the platform runner directories: `android/`, `ios/`, `web/`, `windows/`, `macos/`, `linux/`\n\n## Comments\n\nComments are part of the code and some of them are load-bearing. What this repository limits is the useless and the\nfrequent. Every comment that restates the code devalues the ones carrying real information, until readers skim past all\nof them — a file with three comments that matter is more readable than one with thirty.\n\nThe two halves are enforced differently, because only one of them can be counted. Whether a comment is useless is a\njudgment, and it lives in the rules below and in review. Whether comments are too frequent is arithmetic, and\n`comment-density` measures it.\n\n### What a Comment Must Earn\n\n- Write one when it carries something the code cannot: a non-obvious constraint, an upstream or platform behavior being\n  worked around, a reason the next reader would otherwise get wrong, a coupling to a value defined somewhere else.\n- Never restate what the code already says. If the comment and the line below it convey the same thing, the comment is\n  noise.\n- Never narrate. Not what this diff changed, not what the code used to be, not the order you did things in. Version\n  history belongs in git and change rationale belongs in the commit message.\n- Never annotate line by line or statement by statement. A block that seems to need a comment per step needs better\n  names or a smaller decomposition instead — reach for structure before prose.\n- Prefer encoding the intent in structure and naming. A named mixin, type, or method that makes the invariant hard to\n  break beats a paragraph asking the next reader not to break it.\n- Delete commented-out code, stale version notes, and comments that only restate the code, whenever you edit the file\n  that contains them. This does not need approval.\n- These are not comments and are exempt from every rule here, including the density count: analyzer and linter\n  directives (`// ignore:`, `// ignore_for_file:`, `// coverage:ignore`), license and copyright headers,\n  code-generation markers, and comments inside vendored upstream code such as `lib/ui/open_container.dart`.\n\n### Density\n\n`tool/hooks/comment_density.sh` fails a file whose added lines are more than 5% standalone comment lines, ignoring\ndiffs under 20 added lines so small edits are never caught. The number is calibrated on this repository's own history:\nhealthy changes sit at or under 3.6%, while the core fix that prompted the gate ran 22.4%.\n\nIt is a ceiling on frequency, not a target to fill. Do not read `core/`'s inherited mihomo density as a quota either —\nit is forked upstream code, not a house style.\n\nThree gates run the same script, and they do not have equal force. The `PostToolUse` hooks in `.claude/settings.json`\nand `.codex/config.toml` run after the tool and hand offending lines back as feedback; neither can undo the completed\nwrite. The `comment-density` pre-commit hook fails the commit, and it is the only hard gate that covers every tool.\nBehavior is pinned by `tool/hooks/comment_density_test.sh`, which CI runs through `tool/hooks/test.sh`.\n\nWhen a change genuinely warrants more, raise the ceiling for that run rather than working around it:\n`COMMENT_DENSITY_MAX=20 git commit`, or `SKIP=comment-density git commit` to step past it entirely.\n\n### Where Knowledge Belongs\n\nA comment is one of three destinations, and often the weakest. Pick by where the constraint would be violated, not by\nhow important it feels.\n\n- **Assertable behavior goes in a test.** A test is the only form that cannot drift, because it fails when the behavior\n  it describes is broken. Prefer it over both a comment and a document whenever the fact can be checked in code.\n- **Repository-wide defaults, ownership, and invariants go in `.agents/*.md` or a `.agents/skills/*/SKILL.md`.** They\n  are violated from many files, so they must reach every future agent at session start. A comment in one file cannot do\n  that.\n- **A fact that is true only at one call site, and is not visible from that call site, is the one thing a comment does\n  better than a test or a document.** Its value is being in the reader's line of sight at the moment of the edit.\n  `lib/common/constant.dart` carries one: the delay-test concurrency cap is bound to `delayTestConcurrency` in\n  `core/delay.go`, and whoever changes that number must see the constraint on the same screen. This is the case a\n  comment is for; the bar is that no test and no `.agents/` entry could hold the fact instead.\n\nBoth failure directions are real. Moving a local constraint into `.agents/` hides it from the person editing the line;\nleaving a repo-wide policy as a comment reaches only the reader of that one file.\n\n## Core API Safety\n\n- `core/Clash.Meta` is a fork of mihomo, and changes to it are budgeted for features, not repairs. Fixing a bug there is\n  low priority even when the bug is real and the fix is small: every patch is one more thing to carry across an upstream\n  rebase. Solve it on the FlClash side of the boundary and note the mihomo behaviour you are working around. Reach into\n  the submodule only for a feature that has nowhere else to live, or when the problem is one the FlClash patches\n  themselves introduced — and say which of the two it is in the commit message.\n- Do not expose direct filesystem deletion APIs through Core or helper IPC; use\n  a scope-specific cleanup API instead.\n- The desktop IPC socket in `plugins/rust_api` admits a Unix peer only when its effective uid is the app's own or root:\n  the socket lives in `/tmp`, and the Core connects as root whenever it runs setuid for TUN or under the Linux Helper.\n  A launch mode that runs the Core as any other user has to widen `admit` in `ipc/access.rs`, and Windows\n  keeps its identity check on the Dart side, which compares the named-pipe peer PID with the Core it launched.\n- Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, the\n  gomobile binding, and desktop IPC. Do not double-encode `arguments`, `result`, or event batches. A response whose\n  `file` is set was spilled to disk; a transport resolves it with `resolveSpilledResponse` before reading `result`.\n- `core/message.go` carries three event queues, and the split is load-bearing: state (loaded, geo-update,\n  route-changed), priority (every other event), and bulk (log, request, DNS). Priority and bulk evict their own oldest\n  entry under backpressure, except DNS, which enters the bulk queue through `enqueueState` so a lookup burst cannot push\n  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\n  `UpdatingAction` sweeps it as stale minutes later. Do not merge the tiers or give state eviction semantics. `enqueueState` drops silently on a full\n  queue and must stay that way: reaching it means the host stopped reading, which `logDeliveryError` already reports,\n  and reporting it from the message layer feeds the same batcher.\n- Every method of the callback interfaces in `core/mobile` (`TunHandler`, `ResultHandler`) returns an `error`. gobind\n  clears a Java exception and hands it to Go only for a method that declares one; otherwise the exception stays pending\n  and the next JNI call on that thread aborts the process, so a throw in `protect`/`resolveUid`/`resolvePackage`/\n  `onResult` would crash unrelated code. `core/mobile` turns the error into the answer the system gives when it will not\n  help: `false`, `-1`, or an empty package name.\n- `TunHandler`'s hooks in `core/lib.go` check `callbacks` under `th.mu` before every call. `clear` drops the callbacks\n  while connections may still be resolving, and a call through the nil interface panics on a mihomo goroutine, outside\n  every recovery.\n- The Android bridge resolves an owner in two steps — `ResolveUid` then `ResolvePackage` — because mihomo fills\n  `metadata.Uid` from a procfs lookup that Android Q closed off. Collapsing them back into one call that returns only a\n  package name is what left every connection reporting uid 0, so `UID` rules matched nothing.\n- The desktop delivery path in `core/server.go` must not report failures through `logError`. A log event is published to\n  the log subscriber, batched, and handed back to `send`, so a send failure reported that way feeds itself; use\n  `logDeliveryError`, which writes to stderr and latches until a frame gets through or the next connection is installed.\n  A write that fails without putting a byte on the wire — host backpressure hitting `ipcWriteTimeout`, or a payload above\n  `maxIPCFrameSize` — drops that one frame and keeps the connection: the stream is still framed correctly, and tearing it\n  down here ends the read loop, and with it the Core process. A half-written frame whose write merely timed out is\n  resumed for as long as the stall lasts: Windows Modern Standby suspends the app while the Helper's Core keeps running,\n  so the host can stop draining for hours and still come back, and go-winio reports the expiry as its own `ErrTimeout`\n  rather than `os.ErrDeadlineExceeded`, which is why `send` checks `Timeout()`. The wait has no cap on purpose, and\n  `send` holds `writeMu` throughout, so every other frame — method responses and the single batcher goroutine behind\n  the event queues — waits behind the stalled one. Memory is bounded by the queues; what gives is delivery: the state\n  queue fills and `enqueueState` starts dropping, which is the case the `UpdatingAction` sweep above exists for. Nothing\n  on the Dart side restarts the Core over a stall: `CoreRpcClient` times each pending request out on its own and hands\n  the caller `null` (a `no_response` exception for message methods), and the sweep clears core-scope updating state\n  minutes later. Only a half-written frame that fails outright desynchronizes the stream, and that is the one case\n  `send` closes on.\n- Core method handlers are plain functions that return their answer, registered in the `methodHandlers` table in\n  `core/method.go` through the adapter matching their shape. `dispatchMethodCall` already runs every call on its own\n  goroutine and `handleMethodCall` turns a panic into an `internal_error` answer, so a handler blocks for as long as its\n  work takes and never spawns a goroutine just to answer. Work that outlives the answer goes through `safeGo`, which\n  recovers; a bare `go` puts a panic outside every recovery and kills the process, which on Android is the whole\n  application. The exported Android entry points in `core/lib.go` do not reach `handleMethodCall`, so each one carries\n  its own recovery.\n- A handler fails by returning an `error`: a `*MethodError` travels as it is and anything else under `core_error`. The\n  exception is `withMessage`, for the methods whose result the host reads as a message that is empty on success\n  (`_invokeMessage` in `lib/core/interface.dart`): there a plain error becomes that message and only a `*MethodError`\n  fails the call. Moving a method between the two changes what Dart receives.\n- `dialer.DefaultSocketHook` and `process.DefaultPackageNameResolver` are installed exactly once, by `installHooks` in\n  `core/lib.go`, and never cleared. mihomo checks `DefaultSocketHook` for nil once and dereferences it again when the\n  socket is created (`component/dialer/socket_hook.go`), so clearing it while a dial is in flight calls a nil func\n  value. Stopping the TUN swaps `activeTunHandler` instead.\n- `tunnel.AllProxies()` returns a shared, cached map — never modify it. The cache is invalidated by\n  `invalidateAllProxies` on `tunnel.UpdateProxies` and validated against each provider's `Version()`, so a rebuild\n  costs one read per provider rather than one per proxy. Anything else added to `tunnel/patch.go` that derives from the\n  proxy set needs both signals: the external controller can reload the config through `hub/route/configs.go` without\n  going through FlClash's `applyConfig`, so a hook on the FlClash side alone would miss a profile switch.\n- Core state that mirrors mihomo state goes stale at the next `applyConfig`, which replaces every proxy, provider and\n  rule. Read the tunnel instead of caching a snapshot of it: `lookupExternalProvider` kept one that was rebuilt only\n  when the host asked for the provider list, and the host asks after a successful setup and not after a failed one, so\n  an update ran against a provider the tunnel no longer held — downloading, writing to disk and reporting success\n  against nothing.\n- Selection writes take `selectMu`, not `configMu`. mihomo's `Selector.Set` has no lock of its own, so the writes need\n  mutual exclusion against each other and against `patchSelectGroup` — but not against a whole config apply, which is\n  what `configMu` made a proxy switch wait for, provider downloads included. `patchSelectGroup` takes `selectMu` under\n  `configMu`, fixing the order as `configMu` → `selectMu`.\n- Queueing for the delay-test semaphore and probing the node each get the full timeout in `handleTestDelay`. With one\n  shared deadline, a node that waited behind a saturated semaphore reached `URLTest` with almost nothing left, so a bulk\n  test of a large subscription reported whatever sat at the back of the queue as unreachable.\n- A delay test that the Core does not answer is a fault of the Core or the channel, never a verdict on the proxy:\n  `handleTestDelay` returns within its two timeouts on every path. `asyncTestDelay` therefore returns null instead of a\n  `-1` delay, and `ProxiesAction` leaves the last measurement in place and abandons the rest of the run. Writing a\n  timeout there is what made a reachable node read as unreachable whenever the host deadline beat the Core's.\n- Delay-test progress lives in `pendingDelayTestsProvider`, not as a sentinel value in `DelayDataSource`. A delay of 0\n  used to mean \"testing\", which let a result and the state of a test overwrite each other and left cards spinning\n  forever when the Core restarted. The run owns its keys and releases them in a `finally`, so nothing depends on a\n  reply arriving; core status leaving `connected` cancels every run in flight.\n- Some constants in `lib/common/constant.dart` mirror the Core and move with it. `delayTimedOutValue` is the `-1` that\n  `core/delay.go` reports for a delay test that ran out of time; any other negative value failed outright.\n  `downloadTimeoutDuration` matches mihomo's `resource.DefaultHttpTimeout`, which bounds the Core's provider downloads\n  end to end. `shareLinkSchemes` are the schemes mihomo's `ConvertsV2Ray` reads, less http(s), which is a subscription.\n- Anything on the mihomo side that is reached from both a user-triggered core method and mihomo's own background\n  scheduler needs its in-flight guard on the FlClash side. `updater.UpdateMMDB` and its siblings have none — only the\n  batch `UpdateGeoDatabases` does — and two concurrent runs close the mmap'd database twice, so `handleUpdateGeoData`\n  claims per resource and `updater.GeoUpdateHook` releases.\n- A failed `applyConfig` rolls the tunnel back to the default config, and that rollback is the whole recovery:\n  `handleSetupConfig` returns the error and stops there. Do not add a teardown on top of it — stopping the listeners\n  takes the app offline over a profile the user can still switch away from, and the error already reaches the host,\n  which is what surfaces the failure (`MessageException` on the Flutter side, the config-error toast on Android). Keep\n  an empty `config.yaml` out of the failure path — it is how the app says \"no profile selected\", and `loadConfig`\n  resolves it to the defaults.\n- Package `init` in the Android library runs while the `.so` is being loaded, so a panic there takes the application\n  down before it can report anything. `platform/limit.go` arms an fd-pressure probe and degrades to never blocking when\n  it cannot; keep that shape for anything else `init` sets up that correctness does not depend on.\n- No file in `core/` uses cgo: the only C in the Android library is the glue gobind generates during `gomobile bind`.\n  Android-only files take `//go:build android` and desktop-only ones `!android`, so `GOOS=android CGO_ENABLED=0 go vet`\n  checks the Android side without an NDK. Keep it that way; cgo in `core/` puts the NDK back in front of that vet and of\n  `go build ./...` on every developer machine.\n\n## Lifecycle Rules\n\n- Crash recovery is owned by `BootGuard` (`lib/common/boot_guard.dart`), and the signal it acts on is the persisted\n  `BootRecord`, never a crash reporter. The record is stamped `starting` before `startCore` and `running` once\n  `_initApp` finishes, so only a launch that died before reaching `running` is a failed launch — a crash after hours of\n  runtime, or a process the system reclaimed in the background, is not one. `ApplicationExitInfo` can only veto a\n  failure (user stop, low memory, signal, package change), never create one, and its timestamp is consumed through\n  `handledExitAt` so the same exit cannot be counted twice.\n- `BootGuard` is an Android-only mechanism and gates itself: on every other platform `evaluate`, `markRunning` and\n  `markClosed` return without touching preferences. The desktop has no `ApplicationExitInfo` equivalent to attribute an\n  exit, so a bare sentinel there would read a window closed on the disclaimer dialog, or a session ended by shutdown,\n  as a failed launch. Keep the platform check inside the guard; callers in `bootstrap.dart` and `SystemAction` stay\n  unconditional.\n- Recovery is graded: the first failed launch only skips `initStatus`, and `currentProfileId` is cleared only from\n  `crashRecoveryClearThreshold` consecutive failures on. Do not let a single interrupted launch write to the config.\n- Desktop process ownership belongs to `DesktopCoreLifecycle`; do not start/kill `FlClashCore` from providers, widgets,\n  managers, or ad hoc exit callbacks. Acquire and release it through a `CoreProcessLease`.\n- `CoreController.close()` and platform `close()` implementations are terminal and idempotent. Application shutdown must\n  stay centralized in `SystemAction`/`SystemExitCoordinator`.\n- Android start/stop MethodChannel calls are optimistic UI commands. Keep latest-wins arbitration in native\n  `ServiceState`; do not add a Flutter completion callback that creates a second lifecycle owner. What flows back is\n  the revision-stamped `stopped` report, which Flutter mirrors with `setRunning(false)` and never answers with a\n  decision of its own.\n- Android `RunState` is derived in `ServiceStateMachine.publish`; do not assign it from a transition step, and do not\n  add a second \"is it running\" field beside `ServiceController.runtime`. Every divergence between the tile and the\n  service so far came from a copy that one path forgot to update.\n- Android service callbacks are not automatically user intent. Route explicit Quick Settings, Always-on VPN, and revoke\n  actions through `ServiceState` and keep `ServiceController` as the sole binding/run-time owner.\n- Every `BroadcastReceiver.goAsync()` path must finish its `PendingResult` exactly once. A watchdog may release the\n  broadcast lease, but must not cancel, reverse, or otherwise redefine the service operation.\n- Anything that probes through the outbound route is a `RoutedProbe` judged against `routeTrackerProvider` (Route\n  Consistency in `.agents/architecture.md`); the Core stamps every answer with its counters and hits and checks the\n  hits again on a move, so do not add a change signal, a trigger counter, or a `coreStatusProvider` watch of your\n  own.\n- Presentation smoothing such as `CoreStatusButton`'s connecting hold must remain local display state. It must not delay or\n  overwrite `coreStatusProvider`, and a real failure must bypass/cancel the hold immediately.\n- Safe mode (`SAFE_MODE` define, see the safe mode section of `.agents/architecture.md`) is consulted by each host\n  integration at its owner, never by rewriting the user's settings. `safeModeBuild` is for handles created before the\n  container exists; everything else reads `safeModeProvider` so a test can override it.\n- `Tray.hide()` is idempotent on all three desktop platforms and returns native state to \"`show` was never called\".\n  `AppTray.shutdown()` latches, so no later `update()`/`updateTitle()` can resurrect the icon once shutdown begins.\n  Keep it that way; a resurrected icon outlives `exit(0)` as a Windows ghost icon, because `setPreventClose(true)`\n  means `WM_DESTROY` never runs.\n- The `tray` plugin owns call ordering, idempotency, serialization, and unchanged-payload suppression. Application code\n  declares desired state through one `Tray.show(TraySpec)` call and must not add platform branches to work around\n  ordering. Platform branches in `lib/common/tray.dart` are only for deliberate product differences (macOS speed title\n  and group submenus); query `Tray.instance.capabilities` for ability differences.\n- Every native `show` returns whether the tray accepted the payload, and reports `false` instead of showing a broken\n  icon. `Tray` caches the payload signature only on `true`, so a rejected `show` is retried by the next update rather\n  than suppressed until restart. Windows accepts a payload the shell refused, as at sign-in, and retries the icon\n  natively until it lands, so `Tray` still treats it as visible and `hide` reaches native to delete an add that may\n  have landed. Any test that mocks the `tray` channel must return `true` from `show`.\n- Reading the Wi-Fi SSID is opt-in work, not ambient state. `ConnectivityManager` reads it only while `excludeSSIDs` is\n  non-empty, because that list is its only consumer through `suspendProvider`, and the read costs a blocking platform\n  call plus a location permission on Android and macOS. A second consumer must widen that gate, not drop it.\n- On Android the `wifi_ssid` permission is only `granted` once `ACCESS_BACKGROUND_LOCATION` is held too (Q+). Fine\n  location alone reads the SSID in the foreground but returns `UNKNOWN_SSID` once the app is backgrounded, which is\n  where on-demand spends most of its time. `getSsid` itself only needs fine location, so the split stays inside the\n  plugin.\n- No `wifi_ssid` implementation may answer `getSsid` on the platform thread. Android resolves through a network callback\n  with a timeout, Linux through `g_task_run_in_thread`, and macOS through `ssidQueue` after checking the location\n  authorization, because CoreWLAN reaches `wifid` over XPC and a wedged daemon would freeze the window. Windows is the\n  outstanding exception: `WlanQueryInterface` still runs inline, since replying off-thread there needs a window-message\n  hop that the plugin API does not provide.\n- Android's `system` nameserver resolves only through the DNS `NetworkObserver` reports; mihomo builds it with no\n  fallback, so with no list every lookup through it fails at once. The Core tests delays and updates providers with no\n  service running, so the observer lives for the process from the first `init` or native `quickSetup` and never sends\n  an empty list on a service stop.\n- The delayed DNS re-check `NetworkObserver.onLosing` posts is deliberately left un-deduplicated. The runnable\n  re-reads `networkInfos` and does nothing when `onLost` already dropped the network, and `updateDns` calls into the\n  Core only when the resolved list or the preferred network changed, so a network that reports `onLosing` repeatedly\n  costs two comparisons per event. Holding the pending `Runnable` to `removeCallbacks` it changes no behaviour and adds\n  state that has to stay in sync with the map.\n\n## UI Isolate Work\n\nEverything the UI isolate runs synchronously shares a frame with build, layout and paint: 16 ms at 60 Hz, 8 ms at\n120 Hz. Computation may take as long as it needs anywhere else, so this rule is about where work runs, not how much of\nit there is.\n\n- Work that grows with the user's data (profiles, rules, proxies, groups, dialers, icons, connections) and can take\n  more than about 8 ms on the largest data the app expects runs in an isolate, through `compute` or `Isolate.run`.\n  Thousands of rules or proxies and hundreds of groups are in that range. `makeRealProfileTask` in\n  `lib/common/task.dart` and the backup tasks in `lib/common/backup.dart` are the existing shape.\n- Make the algorithm linear or cached first. Work superlinear in that data, such as a walk of the whole graph per\n  item, a scan of every group per group, or a list copy per inserted element, is a defect on any isolate; moving it\n  only hides the cost until the data outgrows the move.\n- Sending data to an isolate copies it on the sending isolate, so hand the task the smallest input it reads. A value\n  the UI reads synchronously, such as a sync provider watched in `build`, becomes an async provider with a loading\n  state rather than a blocking call.\n- drift runs SQL on its own isolate but maps rows into models on the caller's, so a watched query over a large table\n  costs the UI isolate on every emission, and any write to that table makes it emit again.\n- Bounded work stays where it is: a provider recompute that reuses a cache, a single-item update, a search filter over\n  a list sized for scrolling.\n- An editor document stays in the Rust rope. Open a file for `EditorPage` with `openEditorFile` rather than reading it\n  into a string, and read an edited one through `EditorContent`; a profile can be tens of megabytes.\n- Decide on measurements taken at the expected maximum. A `flutter test` probe that counts provider recomputes, times\n  the provider body and hooks `debugOnRebuildDirtyWidget` shows what reruns and how it scales; its JIT timings are not\n  device numbers, so a case near the line is settled with a profile build. No gate checks this rule.\n\n## Testing Rules\n\nThe `core/` directory is excluded from automated coverage accounting. Do not add coverage instrumentation or coverage\ncollection for code under `core/`. CI still runs `CGO_ENABLED=0 go test .` and `go vet .` to compile/check the Go wrapper,\nplus a `GOOS=android` vet that covers the `android` files the first two exclude; verify cross-language\nprotocol behavior through shared Dart contract tests under `test/core/` and native platform build checks.\n\nA Go test in `core/` that reaches `sendMessage` — directly, or through `handleStartLog` or `updater.GeoUpdateHook` —\nleaves events in the process-wide batcher, which flushes them up to `messageBatchInterval` later into whichever\nconnection `captureFrames` has installed by then. Either keep the event out of the batcher or end the test with\n`settleMessageBatcher`. That batcher runs for the whole test binary and reads `conn` under `connMu`, so install a test\nconnection with `swapConn`; a bare assignment races every event it happens to be delivering, and `go test -race` catches\nit in an unrelated test.\n\nRegister fallback values for freezed params used with `any()` matchers.\n\n`tool/check_coverage.dart` enforces a total floor passed by CI plus per-group floors declared in `_groupFloors`. Raise a\ngroup's floor when new tests lift it; do not lower one to make a run pass.\n\nEvery measured group needs a floor. A group the report measures but `_groupFloors` does not declare fails the run, so\nadding a top-level directory under `lib/` means adding its floor in the same change. Set a new floor at or just below\nthe coverage the directory actually has; the point is to stop a slide, not to backfill tests before the directory can\nland.\n\nPrefer `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))` over `CoreController.test(fake)` in new and\ntouched tests. `CoreController.test` claims the process-wide singleton, which makes a global read and a provider read\nresolve to the same fake, so it cannot fail on a call site that still reaches for the global. A test that does claim\nthe singleton calls `CoreController.resetInstance()` in `tearDown`. It also only reaches the global `coreController`\nwhen nothing has read it yet: that is a top-level `final`, bound to whichever instance existed at the first read, which\nin a shared isolate is another file's.\n\n### Shared Test Isolate\n\n`tool/test.dart` imports many test files into one entrypoint, so process-wide state outlives the file that set it, and\nevery file's `main()` runs before any test of the entrypoint does. Keep `main()` to declarations and\n`TestWidgetsFlutterBinding.ensureInitialized()`: a mock installed there is replaced by the next file's before a test\nsees it. Set up what the tests need in `setUp` or `setUpAll` instead, from the helpers that rebind the singletons:\n\n- `appPath` resolves its directories once per process. Point them at a test's directory with `await useAppPaths(dir)`\n  from `test/helpers/app_paths.dart`, not with a `PathProviderPlatform` fake, which only works while nothing has asked\n  `appPath` yet.\n- `preferences` opens its store once per process. Use `await mockPreferences(values)` from\n  `test/helpers/preferences.dart` rather than `SharedPreferences.setMockInitialValues` alone, which leaves `preferences`\n  on the store it already opened.\n\nBoth cache the resolved value, not the future that produced it. A future completed in one test's fake-async zone runs\nits listeners in that zone, so a later test that awaits it never resumes. Await the helpers outside `testWidgets`\nbodies for the same reason.\n\nA test that only passes in a fresh isolate is leaning on a global it did not set up: `database_test.dart` once caught\nits write failures only because `currentAppLocalizations` threw before anything had loaded them. The script reruns such\na file alone so it cannot fail the run, and lists it; fix it where it reads the global.\n\nConstruct the Android lib handler with `CoreLib.scoped(fakeService)`. The `service` global is gated on `Platform.isAndroid`\nand is therefore null on every test host, so a `CoreLib()` built from it silently takes the null-service fallback on every\npath. `CoreLib.scoped` binds an explicit `Service` instead; reset the singleton with `CoreLib.resetInstance()` in `tearDown`.\n\nThree globals in `lib/common` reach real host state and carry a `@visibleForTesting` seam to stand in front of it:\n`AutoLaunch.launcher`, `listNetworkInterfaces` and `LinkManager.uriLinkStream`. Replace the launcher in particular — every\n`enable`/`disable`/`isEnabled` writes the actual autostart entry (a LaunchAgents plist, a `.desktop` file or a registry\nkey), so a test that skips the seam registers the test binary on the machine that ran it. `updateStatus` returns early\nunder `kDebugMode`, which is always true beneath `flutter test`, so its remaining branches cannot be reached from a test\nat all; `test/common/launch_test.dart` pins the early return instead.\n\n`pumpAndSettle` never returns on a page holding `EditorView` (what `EditorPage` embeds): the code editor blinks its\ncaret forever, so frames keep being scheduled. Pump explicitly instead. `encodeYamlTask` and its neighbours in\n`common/task.dart` hand work to a real isolate through `compute`, which only runs outside the fake-async zone, so a test\nawaiting one needs `tester.runAsync(...)` between the pumps.\n\nThe `@visibleForTesting` `database` setter in `lib/database/database.dart` deliberately does not close the instance it\nreplaces. Tests inject `NativeDatabase.memory()`, which holds no file handle, and `Database.close()` is async while the\nsetter is not, so closing there would either be unawaited or force the seam to become async for no gain. A test that\ndoes open a file-backed database owns closing it.\n\n`system.isAndroid` / `isMacOS` / `isWindows` / `isLinux` read `dart:io` `Platform` and cannot be overridden, unlike\n`debugDefaultTargetPlatformOverride`. A branch behind one of them is only ever exercised on a host that matches it, so CI\n(`ubuntu-latest`) and a macOS working copy measure different coverage for the same test. Assert host-agnostic behavior,\nand leave headroom under a group floor that covers such a branch.\n\nA platform decision that drives layout takes `isDesktop`/`isMacOS` as parameters and reads `system` only at the call site,\nso every platform's outcome is reachable from one host. `getWindowHeaderHeight` and `showsWindowHeader` in\n`lib/common/layout.dart` own the window header rule for both `WindowHeaderContainer` and `overlayTopOffset` — they must\nagree, or the content is offset by a header that is not there. `WindowHeaderBar` takes its height and slots as arguments,\nwhich is what lets `test/manager/window_header_test.dart` measure the Windows caption bar on a macOS host.\n\nA `Stack` under loose constraints sizes to its non-positioned children, and falls back to `constraints.biggest` only when\nit has none — so a bar that fills the window must keep every slot positioned. One non-positioned child is enough to\ncollapse `WindowHeaderBar` to that child's width: the macOS title did exactly that, leaving the app name over the traffic\nlights and the raw window painted black beside it, while Windows, whose slots are all positioned, stayed correct.\n`WindowHeaderLayout` positions the header across the top for the same reason, and the macOS group asserts the bar's\nwidth, not just its height — a height-only assertion cannot see this.\n\nThe same seam carries the two other places a platform decision changed what was built: `AppTray` holds `isMacOS`/\n`isWindows` as state — `AppTray()` fills them from `system`, `AppTray.forPlatform` is the test seam — and `OnDemandView`\ntakes nullable `isAndroid`/`isMacOS` that fall back to the host. Every test names the platform it means, because\n`debugDefaultTargetPlatformOverride` does not move `system` and a suite that leaves it to the host asserts the macOS\nshape on a developer machine and the Linux one on CI. `WindowHeaderContainer` builds the caption buttons on every\nnon-macOS host, so a test mounting it needs `TestApp` for `AppLocalizations` and a `window` channel mock that\nanswers `isMaximized`/`isAlwaysOnTop` with a bool.\n\nAuto-dispose providers need a container-level hold before a test reads them back. `proxyGroupProvider`, `ruleProvider`,\n`itemsProvider` and friends mix in `AutoDisposeNotifierMixin`, so a `container.read` that no widget is currently watching\nrebuilds the provider from its override and silently discards whatever the code under test wrote. Add\n`container.listen(theProvider, (_, _) {})` in the harness, as `test/providers/action_test.dart` does for `scriptsProvider`.\n\nA field that constructs its own `ValueNotifier`, `TextEditingController`, `ScrollController`, `FocusNode`, `TabController`,\n`PageController`, `AnimationController` or `StreamController` must be released in the same file.\n`test/lint/disposable_field_test.dart` enforces this by scanning `lib/`, because no lint covers it: `close_sinks` only sees\nsinks, and nothing in the standard set tracks `ChangeNotifier` disposal. A field that genuinely outlives its owner goes in\nthat test's `_allowed` set with the reason, not left bare. Controllers received as widget parameters belong to the caller\nand are out of scope.\n\nAn `IconButton` whose icon is an icon needs a `tooltip`. It is the button's only accessible name — without it TalkBack and\nVoiceOver announce nothing and the desktop build shows no hover hint. `test/lint/icon_button_tooltip_test.dart` enforces\nit and skips exactly two shapes: an `icon:` holding a `Text`, which is already a visible label, and\n`views/dashboard/widgets/core_status_button.dart`, which takes its label from an enclosing `Tooltip` (a second test fails\nif that wrapper disappears). Reuse an existing string before adding one; a label that depends on state goes on the button\ninside the `ValueListenableBuilder`, not outside it, or the tooltip cannot follow the icon. A row of window buttons hidden\nbehind `system.isMacOS` is unreachable from a macOS test host, so extract it — `WindowHeaderActions` is the pattern.\n\nA tooltip needs an `Overlay` ancestor at build time, not at hover time: `RawTooltip` builds an `OverlayPortal`, so a\nbutton whose `tooltip` has nowhere to go throws \"No Overlay widget found\" and takes its whole subtree down with it.\nEverything `buildManagerStack` wraps around `MaterialApp.builder`'s child sits *above* the app Navigator and therefore\nabove the only Overlay in the tree. A manager that renders a tooltip — `WindowHeaderLayout` and its caption buttons are\nthe case that broke Windows while macOS, which renders a bare title there, stayed clean — hosts its own with\n`Overlay.wrap`, spanning the window so the tooltip is not clipped to the widget that owns it. A test only reproduces this\nby mirroring that topology: build the widget from `MaterialApp.builder`, never from `home:`.\n\nA public top-level declaration that nothing outside its own file references is dead, and no lint catches it:\n`unused_element` covers only private ones, and a barrel `export` keeps a dead file compiling and off every\n\"unused import\" report. `test/lint/dead_file_test.dart` scans `lib/` for files whose declared names — types plus the\n`final appPath = AppPath()` singletons next to them — appear nowhere else, counting generated code as a consumer (a\nriverpod notifier is reached through its generated provider) and barrels as neither. Files publishing only extensions or\ntypedefs are skipped: those are reached through the types they attach to, never by name.\n\n`CommonScaffold` runs its body under the system bar at the foot of the window, as Android's edge-to-edge guidance\nasks, and only the end of the content clears it. A three-button bar is no exception: `ThemeManager` leaves it\nAndroid's translucent scrim (`systemNavigationBarContrastEnforced`). Telling the two apart by the side back-swipe\nzones Flutter reports reserved a gesture handle again on a real phone, so no page asks which bar it has. A scroll view\nends, and anything fixed at the foot of a body stands, `context.bottomInset` above the edge: `BottomInsetScope` (the\nhome dock, a docked search) plus the system bar `MediaQuery` still carries. The home shell takes the system bar out of\n`MediaQuery` for its tabs because the dock's inset already counts it. A bare `BottomInsetScope.of` leaves the system\nbar out, so code outside `lib/ui` never reads it. The scaffold adds its docked search to the scope around its body, so a page with a `searchState` reads\n`context.bottomInset` below the scaffold, inside `body: Builder(builder: (context) => ...)`; read with the context the\nscaffold is built from, it leaves the end of the content under the search field.\n`test/lint/scaffold_bottom_inset_test.dart` enforces both by scanning `lib/`.\n\nEvery reorderable list builds its drag proxy with `commonProxyDecorator`, which tells `DecorationListItem` it is lifted:\nthe row drops its separator and rounds from its corners in the run to a card of its own as it lifts, and back as it\ndrops. A list without it lifts a middle row with square edges. `test/lint/reorder_proxy_decorator_test.dart` enforces\nthis by scanning `lib/`.\n\nA `State.dispose()` override must not await before `super.dispose()`. `StatefulElement.unmount` calls `dispose()` and then\nimmediately asserts that `super.dispose()` already ran, so an `await` defers the call past the assert and every teardown\nthrows \"`…State.dispose failed to call super.dispose.`\" in debug and profile builds. Declare the override as `void\ndispose()` and hand async teardown to `unawaited(...)`; `Future<void> dispose() async` compiles and is the shape that\ninvites the bug.\n\nUse `ProviderContainer` directly for simple Riverpod provider tests, released with `addTearDown(container.dispose)`.\nAssert against the provider's generated public API, not its implementation, re-read source defaults before asserting\nthem, and wait on provider futures or state changes rather than fixed sleeps. The generated Riverpod `update()` method\ntakes a callback:\n\n```dart\nnotifier.update((state) => newValue);\n```\n\nWhen testing freezed models with nested objects, always round-trip through `jsonEncode` and `jsonDecode`. Direct `fromJson(toJson())` fails for nested freezed types because `toJson()` stores child objects directly instead of maps.\n\nFor async widgets, put visual cleanup in `finally` when the action may throw. Focused widget tests should cover success,\nfailure, disposal, and any timer boundary that changes visible state.\n\n## Running the App Locally\n\n- An agent starts FlClash on the host only as a safe mode build (`--dart-define=SAFE_MODE=true`, see\n  `.agents/commands.md`). A normal build takes over the host network, so the user runs it, never the agent.\n- Never end a FlClash process the agent did not start, and never kill by name (`pkill`, `killall`, a pattern on the\n  executable path): the user's own instance matches too. Record the pid of every process you launch, check with\n  `ps -o pid,command -p <pid>` that its executable is your own build output, and end only that pid.\n- Only a macOS or Android debug build has its own data directory; every other build shares the installed app's data\n  directory and single-instance lock, so it cannot start while the user's instance runs. `.agents/commands.md` lists\n  which builds are kept apart.\n\n## Commit Messages\n\nSubjects follow Conventional Commits and are enforced by the `commit-msg` hook in `.pre-commit-config.yaml`, which runs\n`tool/hooks/commit_msg.sh`:\n\n```text\n<type>[(scope)][!]: <description>\n```\n\n- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`.\n- Scope is optional and lower case; use a comma to list several, as in `fix(core,android)`.\n- `!` before the colon marks a breaking change.\n- Descriptions start in lower case, omit the trailing period, and keep the whole subject within 100 characters.\n  Identifiers and acronyms keep their own casing, as in `fix(ui): AppBar text is truncated`.\n- `Merge`/`Revert` subjects and `fixup!`/`squash!` commits are exempt.\n- No `Co-authored-by` trailer crediting a coding agent, whatever that tool's own convention says. The history\n  records who owns the change, not which tool typed it; human co-authors are still fine. The hook rejects the\n  known agent identities.\n\nWrite what the change does, not that something changed: `perf(views): stop redoing per-frame work in build`, not\n`Optimize more details`.\n\nInstall the hooks once with `pre-commit install --hook-type pre-commit --hook-type pre-push --hook-type commit-msg`.\n\n### Fixup Commits\n\nA follow-up to a change that has not shipped in a stable release goes in as `git commit --fixup=<target>`, aimed at the\ncommit that introduced what it amends.\n\n- 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\n  for fixups, so do not steer away from it or flag it.\n- A commit a stable tag contains takes no fixup; land the follow-up as a regular commit. Check with\n  `git tag --contains <target> | grep -v -- '-pre'`, which must print nothing.\n- Asking for a fixup means creating the `fixup!` commit only. Do not run `git rebase --autosquash` or otherwise fold it\n  into its target unless the user asks for the squash.\n\n### Changelog Trailers\n\n`tool/changelog.dart`",".codex/config.toml":"[[hooks.PostToolUse]]\nmatcher = \"apply_patch|Bash\"\n\n[[hooks.PostToolUse.hooks]]\ntype = \"command\"\ncommand = \"bash tool/hooks/comment_density.sh\"\ntimeout = 15\nstatusMessage = \"Checking comment density\"\n",".agents/skills.md":"# Agent Skills\n\nRepo skills live under `.agents/skills/*/SKILL.md`, where Codex discovers them directly. Claude Code discovers them\nthrough the symlinks in `.claude/skills/`, one per skill, pointing back at the `.agents/skills/` directory. Both tools\nmatch a task against the skill `name` and `description`, then load the full instructions only when it matches.\n\n## Available Repo Skills\n\n- `localization`: hardcoded UI text scans, ARB updates, locale generation, and localization verification.\n- `ui-work`: Flutter UI, widgets, Material You styling, icons and glyphs, shapes and corner radii, navigation surfaces,\n  async feedback, and user-facing interactions.\n- `core-platform`: Core lifecycle/process ownership, Android services, Go event delivery, desktop IPC, platform managers,\n  build hooks, system proxy, tray, VPN/TUN, and the Windows and Linux Helper.\n- `pre-commit-quality-review`: lightweight triage before commits, with scoped structural review and user decisions on concrete regressions.\n\n## Authoring Notes\n\n- Add new repeatable workflows as `.agents/skills/<skill-name>/SKILL.md`, and add the matching\n  `.claude/skills/<skill-name>` symlink so Claude Code sees it too; remove both when retiring a skill.\n- Git stores the symlinks as links only where the checkout supports them. A Windows checkout needs\n  `core.symlinks=true` and Developer Mode, otherwise the links become plain files and Claude Code loads no repo\n  skill there; Codex is unaffected because it reads `.agents/skills/` directly.\n- Keep skill descriptions trigger-focused and start them with `Use when...`.\n- Keep long reference material in `.agents/*.md`; skills should link to it instead of duplicating it.\n- Put command permission rules in `.codex/rules/*.rules`, not in this file.\n",".claude/skills/localization":"../../.agents/skills/localization",".claude/skills/pre-commit-quality-review":"../../.agents/skills/pre-commit-quality-review",".claude/skills/core-platform":"../../.agents/skills/core-platform",".claude/skills/ui-work":"# Claude Directive: ui-work\n\nPath: `.claude/skills/ui-work`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/.claude/skills/ui-work)","plugins/code_forge/README.md":"# code_forge Documentation\n\nPath: `plugins/code_forge/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/code_forge/README.md)","plugins/code_forge/analysis_options.yaml":"# Code_forge Skill\n\nPath: `plugins/code_forge/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/code_forge/analysis_options.yaml)","plugins/code_forge/pubspec.yaml":"# Code_forge Skill\n\nPath: `plugins/code_forge/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/code_forge/pubspec.yaml)","plugins/proxy/CHANGELOG.md":"# Proxy Skill\n\nPath: `plugins/proxy/CHANGELOG.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/CHANGELOG.md)","plugins/proxy/README.md":"# proxy Documentation\n\nPath: `plugins/proxy/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/README.md)","plugins/proxy/analysis_options.yaml":"# Proxy Skill\n\nPath: `plugins/proxy/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/analysis_options.yaml)","plugins/proxy/pubspec.yaml":"# Proxy Skill\n\nPath: `plugins/proxy/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/pubspec.yaml)","plugins/rust_api/CHANGELOG.md":"# Rust_api Skill\n\nPath: `plugins/rust_api/CHANGELOG.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/CHANGELOG.md)","plugins/rust_api/CLAUDE.md":"# Rust_api Skill\n\nPath: `plugins/rust_api/CLAUDE.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/CLAUDE.md)","plugins/rust_api/README.md":"# rust_api Documentation\n\nPath: `plugins/rust_api/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/README.md)","plugins/rust_api/analysis_options.yaml":"# Rust_api Skill\n\nPath: `plugins/rust_api/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/analysis_options.yaml)","plugins/rust_api/flutter_rust_bridge.yaml":"# Rust_api Skill\n\nPath: `plugins/rust_api/flutter_rust_bridge.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/flutter_rust_bridge.yaml)","plugins/rust_api/pubspec.yaml":"# Rust_api Skill\n\nPath: `plugins/rust_api/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/pubspec.yaml)","plugins/setup/analysis_options.yaml":"# Setup Skill\n\nPath: `plugins/setup/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/setup/analysis_options.yaml)","plugins/setup/pubspec.yaml":"# Setup Skill\n\nPath: `plugins/setup/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/setup/pubspec.yaml)","plugins/tray/README.md":"# tray Documentation\n\nPath: `plugins/tray/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/tray/README.md)","plugins/tray/analysis_options.yaml":"# Tray Skill\n\nPath: `plugins/tray/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/tray/analysis_options.yaml)","plugins/tray/pubspec.yaml":"# Tray Skill\n\nPath: `plugins/tray/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/tray/pubspec.yaml)","plugins/wifi_ssid/analysis_options.yaml":"# Wifi_ssid Skill\n\nPath: `plugins/wifi_ssid/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/wifi_ssid/analysis_options.yaml)","plugins/wifi_ssid/pubspec.yaml":"# Wifi_ssid Skill\n\nPath: `plugins/wifi_ssid/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/wifi_ssid/pubspec.yaml)","plugins/window/README.md":"# window Documentation\n\nPath: `plugins/window/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/window/README.md)","plugins/window/analysis_options.yaml":"# Window Skill\n\nPath: `plugins/window/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/window/analysis_options.yaml)","plugins/window/pubspec.yaml":"# Window Skill\n\nPath: `plugins/window/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/window/pubspec.yaml)","plugins/setup/setup_hooks/analysis_options.yaml":"# Setup_hooks Skill\n\nPath: `plugins/setup/setup_hooks/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/setup/setup_hooks/analysis_options.yaml)","plugins/setup/setup_hooks/pubspec.yaml":"# Setup_hooks Skill\n\nPath: `plugins/setup/setup_hooks/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/setup/setup_hooks/pubspec.yaml)","plugins/proxy/windows/.vscode/settings.json":"# .vscode Skill\n\nPath: `plugins/proxy/windows/.vscode/settings.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/windows/.vscode/settings.json)","plugins/rust_api/rust/tests/fixtures/profile_config.json":"# Fixtures Skill\n\nPath: `plugins/rust_api/rust/tests/fixtures/profile_config.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/rust/tests/fixtures/profile_config.json)"},"files":{".agents/skills/core-platform/SKILL.md":"---\nname: core-platform\ndescription: 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.\n---\n\n# Core And Platform\n\n## When To Use\n\nUse this for changes touching `lib/core/`, `lib/manager/`, `core/`, `services/helper/`, Android app/service modules,\nbuild hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior.\n\n## Read First\n\n- `.agents/rules.md`: Core API Safety and Lifecycle Rules are binding. They carry the mihomo-fork patch budget, the\n  JSON envelope and `core/message.go` queue rules, gomobile callback rules, `BootGuard`, tray, and SSID rules, and every\n  lifecycle invariant (latest intent wins, desktop ownership through a `CoreProcessLease`, terminal `close()`,\n  optimistic Android commands arbitrated by `ServiceState`, callbacks are not user intent, `goAsync()` finishes once).\n- `.agents/setting-changes.md`: a setting the Core or the Android VPN reads is patched live, re-applied with the\n  profile, or left to a restart the user confirms; it says which, and what adding a setting to each path takes.\n- `.agents/architecture.md`: Core Integration, Lifecycle Ownership And Convergence, Core Protocol And Event Delivery,\n  Safe Mode, Build System, and Rust Helper Service describe the current mechanisms, including the Helper protocol,\n  the applied/coalesced/superseded outcomes, and the unconfirmed-exit lease.\n\n## Owners\n\nEvery behavior here has one authoritative owner, and a change lands there:\n\n- Shared facade/protocol: `lib/core/controller.dart`, `lib/core/interface.dart`, and `lib/core/method.dart`.\n- Android Core connection: `lib/core/lib.dart`, `lib/plugins/service.dart`, and Android `ServicePlugin`.\n- Android start/stop intent: `ServiceState`; binding/process-time bookkeeping: `ServiceController`.\n- Desktop composition: `lib/core/service.dart`; lifecycle/process ownership: `lib/core/desktop/lifecycle.dart`.\n- Desktop IPC/RPC: `lib/core/desktop/transport.dart` and `lib/core/desktop/rpc_client.dart`.\n- Desktop launch ownership: `lib/core/desktop/launcher.dart`; Windows and Linux Helper HTTP contract:\n  `lib/core/desktop/helper_client.dart` and `services/helper/`.\n- Flutter orchestration: `lib/providers/actions/core.dart` and `system.dart`; application exit:\n  `SystemExitCoordinator`; UI/event observation: `lib/manager/`.\n\nAn owner has more entry paths than the UI: provider calls, Quick Settings, notification actions, Always-on VPN, revoke\ncallbacks, application exit, and crash/disconnect recovery all reach it. Always-on startup is explicit through\n`VPN_START_REQUESTED` and revoke through `VPN_REVOKED`; service creation or destruction is never read as intent.\n\n## Verification\n\nFocused tests live at the narrowest layer, and the matching commands are in `.agents/commands.md`:\n\n- Desktop lifecycle/transport/RPC: `test/core/desktop/` plus `test/core/service_test.dart`.\n- Cross-language envelopes/events: `test/core/protocol_contract_test.dart` and `CGO_ENABLED=0 go test .`.\n- Provider/exit convergence: `test/providers/action_test.dart` and `test/providers/system_action_test.dart`.\n- Android Kotlin: compile each touched Gradle module with JDK 17.\n- Helper: `cargo fmt --check` and `cargo test`; the `windows-service` feature only builds on Windows.\n\nState host gaps explicitly. Always-on VPN, VPN permission, system revoke, named-pipe peer identity, polkit and systemd\non Linux, and Windows Service Control Manager behavior need their real platform even when portable tests pass.\n\n## Pitfalls\n\n- A `200` from Helper `/stop` means the Core is gone. When termination cannot be confirmed the Helper keeps the child\n  and answers `coreStopFailed`, and `/start` reports the same code instead of spawning a replacement. Keep that code\n  out of the Dart pre-spawn fallback set in `helper_client.dart`, or the direct launch races a Core the Helper still\n  owns.\n- The Helper owns executable integrity: Core SHA256 validation and the protocol stay identical across Flutter build\n  modes, so a debug build is not exempt.\n- `plugins/setup/` is a build harness, not a Dart API plugin, and its hooks can trigger Go or Rust compilation\n  indirectly through Flutter platform builds; a missing toolchain fails `flutter test`, not only `flutter build`.\n",".agents/skills/localization/SKILL.md":"---\nname: localization\ndescription: Use when changing FlClash UI text, scanning for hardcoded Chinese strings, updating ARB localization, or fixing generated locale output in this repository.\n---\n\n# Localization\n\n## When To Use\n\nUse this for localization work in this repository, especially hardcoded UI text in `lib/`, ARB updates, missing translations, or generated `lib/l10n/` output that does not match source ARB values.\n\nDo not use this for README translation sync or manual edits to generated localization Dart.\n\n## Workflow\n\n1. Scan user-facing Dart text before opening many files. `flutter_intl` reads `arb/` and writes `lib/l10n/`:\n\n   ```bash\n   rg -n \"[\\\\p{Han}]\" lib -g '!lib/l10n/intl/**' -g '!lib/**/generated/**'\n   ```\n\n2. Inspect the smallest relevant call sites and nearby ARB keys.\n3. Add or update every source ARB:\n   - `arb/intl_en.arb`\n   - `arb/intl_zh_CN.arb`\n   - `arb/intl_ja.arb`\n   - `arb/intl_ru.arb`\n   - `arb/intl_fr.arb`\n   - `arb/intl_es.arb`\n   - `arb/intl_ko.arb`\n4. Replace inline strings with existing project accessors:\n   - Widgets with `BuildContext`: `context.appLocalizations.key` from `lib/common/context.dart`.\n   - Controllers/providers/non-widget code: `currentAppLocalizations.key` from `lib/common/app_localizations.dart`.\n5. Regenerate:\n\n   ```bash\n   dart run intl_utils:generate\n   ```\n\n6. Run `flutter analyze --no-fatal-infos` and `flutter test test/l10n test/lint/dynamic_message_key_test.dart`. The\n   lint test checks that every locale carries the same keys and that no `Intl.message` builds a key at runtime; labels\n   for enum values go through the switches in `lib/common/l10n_labels.dart`, as the ARB section of `.agents/rules.md`\n   explains.\n7. Re-run the Han-text scan for targeted strings.\n\n## Pitfalls\n\n- A locale that still shows English lacks the key or carries an English value in its source ARB; fix that ARB and\n  regenerate. Do not edit generated Dart.\n- Ignore `lib/l10n/intl/**` and `lib/**/generated/**` during text scans.\n",".agents/skills/pre-commit-quality-review/SKILL.md":"---\nname: pre-commit-quality-review\ndescription: Use when the user asks to commit, amend, or squash code, or requests a pre-commit structural quality review.\n---\n\n# Pre-commit Quality Review\n\nCheck that iterative edits converged into a clear, cohesive, maintainable implementation before they become a commit,\nso later code review is not spent on basic structure. `AGENTS.md` and `.agents/rules.md` apply throughout. This skill\ngrants no authority: commit, push, and history rewrites stay under the user's existing authorization, and silence is\nnot consent.\n\n## Triage\n\nMost commits stop here. Decide from the change summary, the diff, and the local context it needs; never start with a\nrepository-wide scan.\n\n- **Skip** formatting, mechanical renames, generated output, copy changes, and simple local fixes. They go through the\n  normal commit flow without extra confirmation. Say the review was skipped, never that it passed.\n- **Review** when a core implementation is replaced; when responsibilities, dependency direction, or public abstractions\n  change materially; when accumulated patches added branches, duplicate paths, or indirection; or when new code draws\n  responsibility boundaries. A small change can move a critical responsibility, so line count is not a trigger.\n- An explicit structural review request bypasses triage.\n\nThe snapshot under review is the intended commit compared with its baseline:\n\n| Operation | Baseline |\n|---|---|\n| Regular commit | `HEAD` |\n| Amend | Parent of the commit being replaced |\n| Squash | Version preceding the agreed range |\n| Initial commit | Empty tree |\n\nAsk when the range or parent is materially ambiguous instead of assuming the whole branch. Read staged versions of\npartially staged files; an unstaged fix is not part of the pending commit. Preserve unrelated changes and partial\nstaging.\n\n## Review the Final Structure\n\nScope the review to the responsibility boundaries this change touched. Read the complete final implementation of the\naffected units and compare before and after, not only the last patch. Report issues this change introduced or\nworsened; untouched historical debt is not a pre-commit requirement. Follow a call or dependency path only as far as a\nconcrete concern needs, and disclose missing essential context rather than claiming a complete pass.\n\nFocus on:\n\n- **Responsibilities and cohesion:** units mixing responsibilities that change independently; orchestration tangled\n  with implementation detail; responsibilities in layers that should not own them.\n- **Dependencies and abstractions:** reversed or circular dependencies, forwarding layers, abstractions that isolate\n  nothing; similar names or interfaces hiding different responsibilities.\n- **Complexity and readability:** nested conditions, repeated fallbacks, boolean parameter combinations, mutually\n  dependent helpers; names and decomposition that do not reduce reading effort.\n- **Duplication and consistency:** reimplemented existing capabilities, parallel implementations or duplicate state,\n  scattered ownership of error handling, cleanup, or state updates.\n- **Remnants:** obsolete branches, compatibility layers, unused parameters, commented-out code, comments the rewrite\n  invalidated; coupling that makes existing test boundaries hard to use.\n\nNot findings: length, a missing design pattern, naming taste, or anything without code evidence and a real\nmaintenance cost. Do not add cross-layer coupling to remove a few similar lines, rewrite stable code for uniformity,\nor demand abstractions for speculative needs.\n\nBusiness logic, UI behavior, performance, security, platform compatibility, and full functional review are out of\nscope. Report an incidentally found serious defect separately through the normal defect workflow, without hiding it or\nwidening this review.\n\n## Findings and the User's Decision\n\nA finding qualifies only when all three hold:\n\n- Before-and-after code and the necessary call relationships show this change introduced or worsened it.\n- The maintenance cost is concrete, such as updating one rule in two places or touching unrelated layers for one\n  responsibility. Clarity or elegance alone is not enough.\n- A minimal proposal fits existing constraints, with a benefit not offset by new coupling, indirection, or scope.\n\nWith no qualifying finding, state the reviewed scope and conclusion in a sentence or two and continue under the\nexisting commit authorization. Do not ask for confirmation because a review happened, and do not collect optional\nimprovements into a suggestion list.\n\nWith qualifying findings, finish the read-only review and independent checks first, merge findings that share a cause,\nand give for each: file, symbol, and location with how this change introduced it; the concrete maintenance cost; the\nsmallest proposal with its scope and any behavioral impact. Then **pause before creating the commit and ask whether to\noptimize first; do not edit or commit until the user answers.** Attribute the pause to this skill by file path so the\nuser recognizes their configured workflow, for example: \"The structural issues above support consolidating the\nduplicate state-update paths. Should I make that change before committing?\"\n\nExisting explicit authorization for these findings is enough to act without asking again. The user may accept all,\nsome, or none. Keep accepted findings and tradeoffs in the task context only, and do not re-ask because files were\nrestaged, a hash changed, or unrelated edits landed; reassess only when relevant code changes invalidate the tradeoff.\n\n## Resolve and Finish\n\nOnce authorized, make the smallest adjustments that resolve the accepted findings while preserving behavior and task\nscope; explain separately if either must change. Verify only the accepted findings and the direct effects of the\nadjustments, without another search for improvements. Raise a new finding only with evidence that meets the threshold\nand exceeds the existing authorization.\n\nBefore committing, recheck status and the actual staged diff so the pending snapshot matches what was reviewed, and\nreview only the delta of later edits. Run the checks `AGENTS.md` requires for the change: `flutter pub get`,\n`flutter analyze --no-fatal-infos`, and the covering `flutter test` suites listed in `.agents/commands.md`. Reuse\nresults that are still valid, rerun those a later edit invalidated, and never present results from an earlier snapshot\nas verification of the staged one. The `comment-density` and `commit-msg` hooks in `.agents/rules.md` still gate the\ncommit.\n\nReport the reviewed scope, completed improvements or accepted tradeoffs, check results, and any verification limits.\nClaim the commit is done only after it exists.\n","AGENTS.md":"# AGENTS.md\n\nThis file is the entry point for AI coding agents working in this repository. Keep it small: detailed guidance lives under\n`.agents/`, and discoverable repo skills live under `.agents/skills/*/SKILL.md`.\n\n## Start Here\n\nRead these files before making changes:\n\n- [.agents/project.md](.agents/project.md): project overview, versions, and build dependencies.\n- [.agents/commands.md](.agents/commands.md): build, development, code generation, and test commands.\n- [.agents/rules.md](.agents/rules.md): lint, testing, generated-code, and workflow rules.\n\nRead these only when the task touches their area:\n\n- [.agents/architecture.md](.agents/architecture.md): core integration, providers, database, managers, build system, and\n  local plugins.\n- [.agents/setting-changes.md](.agents/setting-changes.md): whether a changed setting is patched live, re-applies the\n  profile, or asks for an Android VPN restart.\n- [.agents/surfaces.md](.agents/surfaces.md): fill levels, selection and status colors, and the few borders that\n  remain, for any change to how a surface is colored.\n- [.agents/agent-config.md](.agents/agent-config.md): how to choose between `AGENTS.md`, `.agents`, skills, Codex config,\n  command rules, and hooks.\n- [.agents/worktrees.md](.agents/worktrees.md): worktree hygiene across Claude Code, Codex, and Gemini.\n- [.agents/flutter-upgrade.md](.agents/flutter-upgrade.md): SDK-pinned workarounds to recheck before changing the\n  Flutter version.\n- [.agents/skills.md](.agents/skills.md): index of repo-scoped skills in `.agents/skills/`.\n\n## Highest Priority Rules\n\n- When the user explicitly requests a scoped, low-risk change, inspect the relevant context and implement it directly.\n  Do not require brainstorming, design documents, implementation plans, multiple-option proposals, or repeated confirmation.\n  Ask only when material ambiguity, destructive impact, additional authority, or scope expansion could change the result.\n- The request sets the scope. A pre-existing bug, cleanup, or refactor the task did not ask for is a follow-up in the\n  summary, not part of the change. Edit a file surgically when a rewrite would give the same result, keep scratch checks\n  out of the repository, and commit tests only where the task asks for them or the neighbouring code already keeps them.\n- Comments are allowed and sometimes necessary; excess is the problem. A comment must carry something the code cannot\n  say — a non-obvious constraint, an upstream behavior being worked around, a reason a reader would otherwise get\n  wrong. Never restate what the code does, narrate the change you just made, record what the code used to be, or\n  annotate step by step; a block that seems to need a comment per line needs better names or a smaller decomposition.\n  Keep density near the repository's own: healthy changes here sit at or under 3.6%, and a `comment-density` gate fails a file\n  whose added lines exceed 5% standalone comments. Delete commented-out code and stale notes in files you already\n  touch. Preserve\n  `// ignore:`-style directives, license headers, codegen markers, and vendored upstream comments. See\n  [.agents/rules.md](.agents/rules.md) for what belongs in a test or in `.agents/` instead.\n- Start FlClash on the host only as a safe mode build, and end only processes you started, by recorded pid. Never\n  kill a FlClash instance by name; the user's own instance is not yours to close. See\n  [.agents/rules.md](.agents/rules.md).\n- Use `flutter test`, not `dart test`, because models pull in Flutter types.\n- Run code generation after modifying models, providers, or database schema.\n- Do not manually edit generated files.\n- Preserve lifecycle ownership: desktop Core process convergence belongs to `lib/core/desktop/`; Android service intent\n  arbitration belongs to `ServiceState`. UI/provider code may request a transition but must not become a second source of\n  truth.\n- Keep start/stop/restart paths latest-intent-safe. Flutter-to-Android service commands are deliberately optimistic, while\n  native state serializes the actual work; desktop lifecycle results distinguish applied, coalesced, and superseded\n  requests.\n- Never add a `Co-authored-by` trailer crediting a coding agent to a commit, even when your own tooling tells you to.\n  The `commit-msg` hook rejects it; see [.agents/rules.md](.agents/rules.md) for the rest of the commit rules.\n- Round every corner with a superellipse, pills included: `AppShape`/`AppRadius` tokens from `lib/ui/shape.dart`,\n  never `StadiumBorder`, `RoundedRectangleBorder`, `ClipRRect`, `drawRRect`, or a `borderRadius` on `BoxDecoration` or\n  `InkWell`. `test/lint/superellipse_corners_test.dart` enforces it; see the Corner Radius section of\n  [.agents/rules.md](.agents/rules.md).\n- Keep the UI isolate within its frame: work that grows with user data and can exceed about 8 ms on the largest\n  expected data runs in an isolate, once its algorithm is linear or cached. See the UI Isolate Work section of\n  [.agents/rules.md](.agents/rules.md).\n- Follow `lint_options.yaml` (included by every `analysis_options.yaml`), especially single quotes, trailing commas, `child:` last, no `print()`, const/final\n  preferences, and declared return types.\n- Before reporting a change done, run `flutter pub get`, `flutter analyze --no-fatal-infos`, and the `flutter test`\n  suites that cover it, as CI does. If a toolchain the build hook needs is missing (Go, and on Linux and Windows\n  cargo), say which check was skipped rather than dropping it silently.\n\n## Repo Skills\n\nUse repo skills from `.agents/skills/` when a task matches their descriptions; `.claude/skills/` symlinks expose the\nsame skills to Claude Code. Current skills cover localization, UI work, core/platform changes, and pre-commit\nstructural quality review.\n",".agents/skills/ui-work/SKILL.md":"---\nname: ui-work\ndescription: 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.\n---\n\n# UI Work\n\n## When To Use\n\nUse this for user-facing Flutter UI changes in `lib/`, including widgets, screens, navigation surfaces, settings rows,\ndialogs, and interaction behavior.\n\n## Conventions\n\n- Reuse the patterns of nearby widgets, providers, notifiers, and helpers before adding an abstraction, and keep to the\n  look the rest of the app has: Material You from `material_ui` as the base, with Apple's interaction conventions\n  (continuous corners, sheets that snap between detents, a floating tab bar) built on Material widgets rather than\n  Cupertino ones. Do not introduce a visual system for one screen or rewrite a layout the request does not need.\n- `.agents/rules.md` is binding here: the lint set (`child:` last, `const`, final locals), the superellipse corner API,\n  and the widget-test facts under Testing Rules (`pumpAndSettle` never returns on `EditorView`, `dispose()` must not\n  await, auto-dispose providers need a container-level hold, platform decisions are passed in as parameters).\n- A component that reads no provider, model or app string goes in `lib/ui/`; one built for an app feature goes in\n  `lib/widgets/`. The UI Layer section of `.agents/architecture.md` says how a `lib/ui` component gets app state.\n- Settings screens are built from the `ConfigToggleItem` family in `lib/widgets/config_item.dart`; the Settings Rows\n  section of `.agents/architecture.md` says when a file-local helper or a named widget is warranted.\n- Every icon lives in `lib/ui/icons/`, never the Material `Icons` font: reuse or add a glyph in `AppGlyphs` and show it\n  with `GlyphIcon`, or with `AnimatedGlyph` where it fills on selection. Glyphs are outlined, except inside a filled\n  button, where they take `fill: 1`. The Icons section of `.agents/architecture.md` covers shape roles, the stroke, the\n  fill animation, and when to fill.\n- Text sizes and weights come from `AppTextStyle` in `lib/ui/typography.dart`, Apple's iOS Dynamic Type scale, never a\n  Material `TextTheme` slot, a bare `fontSize`, or a `fontWeight`; the Typography section of `.agents/rules.md` has the\n  rule.\n- Modals dim what they cover and never blur it: sheets with the Material 3 scrim (`ColorScheme.modalScrim`, `scrim` at\n  32%), alerts with `ColorScheme.alertScrim`, the lighter dim iOS 27 puts behind an alert. Every modal brings its own\n  scrim, including one opened from inside another, so do not make a barrier transparent to keep scrims from stacking.\n- A sheet's size belongs to its route, never to its content or call site: a bottom sheet rests at\n  `SheetProps.detents`, a side sheet is as tall as the window and as wide as `ModalSideSheetRoute` makes it from\n  the window width (`lib/ui/side_sheet.dart`, pinned by `test/widgets/side_sheet_width_test.dart`), and a form sheet\n  is a card `FormSheetRoute` caps in width and height (`lib/ui/form_sheet.dart`). Do not wrap a sheet's content in a\n  fixed width or height to resize one sheet.\n- A form with more than one value opens with `showFormSheet` and lays its values out in `FormRow`s, never as labeled\n  fields in a `CommonDialog`; a prompt for one value stays an alert. Forms in `.agents/architecture.md` has the rule.\n- A modal scrolls only once the window and keyboard leave it no room, and then through one scroll view: `CommonDialog`\n  caps its body at nothing but the space it is given, and a multi-line field in a dialog or sheet grows with its text\n  (`maxLines: null`) rather than scrolling inside a line count. With the keyboard up, a bottom sheet keeps what it\n  shows above the keyboard, rising past its tallest detent as far as a peek below the top (`_top` in\n  `lib/ui/snap_sheet.dart`).\n- The keyboard animates `MediaQuery.viewInsets` every frame. Read MediaQuery by aspect (`paddingOf`, `viewInsetsOf`)\n  rather than `MediaQuery.of`, and wrap the page of a new route type in `KeyboardInsetHold`; Keyboard Insets in\n  `.agents/architecture.md` has the reasons.\n- A page loads what it shows as it mounts, never once its route has settled; what waits out a transition is work\n  that repeats, such as a live feed's refreshes (`RouteMotionHoldMixin`), and the rows past the viewport\n  (`arrivalScrollCacheExtent`). Page Arrival in `.agents/architecture.md` has the rule.\n- A page that waits on its data shows nothing until all of it is ready (`NullStatusSwitcher.isLoading`), and a load\n  that can run long adds its illustration as `loadingStatus`, never a skeleton screen; Page Arrival in\n  `.agents/architecture.md` has the rule.\n- App bar buttons reach `CommonScaffold` as data (`primaryAction`, `iconActions`, `menuItems`) so it can fold them into\n  its overflow menu; App Bar Actions in `.agents/architecture.md` has the rule.\n- A row's secondary actions open from a context menu, never a trailing more button; a list whose rows have one\n  reorders in a sort mode, any other by a long press on the row. Row Actions And Sort Mode in `.agents/architecture.md`\n  has the rule.\n- Quick edit belongs only on a page that edits a whole list or section, or one isolated item; never on one that edits a\n  part of something other settings also write. Quick Edit in `.agents/architecture.md` has the rule.\n- A page or sheet opens on the navigator of the context it comes from, never on the root navigator unless Where A\n  Route Opens in `.agents/architecture.md` allows it.\n- User-facing text goes through ARB; use `localization` when text changes are non-trivial.\n- An asynchronous control keeps four things apart: authoritative provider/domain state; display-only state such as a\n  minimum progress duration; tap policy while work or a display hold is active; and failure/disposal cleanup, in\n  `finally` for animations and timers. `CoreStatusButton` and the proxies delay test action are the reference implementations,\n  described under User-Facing Core And Delay Feedback in `.agents/architecture.md`.\n\n## Corner Radii\n\nAll corner radii come from `lib/ui/shape.dart`: a token, a derived value, or a named off-scale value passed to its\nbuilders. Never write a bare radius literal at a call site.\n\nThe scale is picked by the component's **shortest side**. A radius that reads as a soft card at 64 dp tall\nreads as a pill at 24 and as a square at 400, so one radius everywhere is the wrong kind of consistency.\n\n| Token | Value | Shortest side | Use |\n| --- | --- | --- | --- |\n| `none` | 0 | - | square edges, and the flat side of a grouped run |\n| `xs` | 4 | inset blocks | tiles clipped inside an already rounded surface |\n| `sm` | 8 | up to 48 | chips, thumbs, swatches, small square tiles |\n| `md` | 16 | up to 64 | interactive chrome: inputs, menus, buttons, popups |\n| `lg` | 20 | 64 to 180 | mid-size cards: dashboard tiles, proxy node cards |\n| `xl` | 24 | full-bleed | anything spanning the whole width: list rows, grouped runs, group headers |\n| `xxl` | 28 | over 200 | sheets, full-screen containers |\n| `alert` | 34 | - | alerts, the corner of iOS 27's UIAlertController; on `AppCorner` only, passed to the builders |\n| `full` | 1000 | - | pills and circles: buttons, tracks, indicators, progress, avatars |\n\n- `AppCorner` holds the scale as `double`; `AppRadius` mirrors it as `BorderRadius` with `all`, `top`, and `vertical`\n  builders; `AppShape` mirrors it as `RoundedSuperellipseBorder`, `full` included, plus `circle`, `input`, and the\n  `all`/`top`/`vertical`/`of` builders. Use `AppShape.full` for pills, never `StadiumBorder`; the Corner Radius\n  section of `.agents/rules.md` explains why and lists the superellipse API per layer.\n- `AppCorner.fit(shortestSide)` snaps to the largest token at or under one third of the shortest side. Use it whenever\n  the size comes from a `LayoutBuilder` or scales with text.\n- `ThemeData.withAppShapes` in `lib/ui/shape.dart` already applies the scale to card, dialog, bottom sheet, snack\n  bar, chip, menu, input, FAB, navigation indicator, progress, and the filled, text, outlined, elevated, and icon\n  button themes. Do not restate those shapes at call sites, and\n  leave `InputDecoration.border` unset so inputs inherit `AppShape.input`.\n- Colors of surfaces follow `.agents/surfaces.md`: fills by `SurfaceLevel`, no structural borders, and a selected\n  item keeping its fill under a `SelectionCheck` or `SelectionBadge`, with no container role on a card or row.\n\n`xl` and `lg` exist because one token cannot serve both size classes. A full-bleed surface is wide and short, so its\ncorner must be larger than its height alone suggests or it stops reading as a card; everything full-bleed shares `xl`\n(the profiles card, the outer corners of a grouped run in `DecorationListItem`, the proxy\ngroup card in `lib/views/proxies/list.dart`) and does not use `fit()`. This is the one deliberate overshoot of the\none-third rule; the value stays under half a standard row so nothing clamps and every full-bleed surface renders the\nsame radius. `lg` is for mid-size cards that are not full-bleed: a proxy node card in the tab view at\n`ProxyCardType.compact` is only about 64 tall, which caps its radius at 21, so `xl` would break on a setting the user\ncan change at any time while `lg` clears both heights and the smallest dashboard tile.\n\nOutlined inputs use `AppInputBorder`, not `ShapedInputBorder` from `package:material_ui`. The package border subtracts\nthe floating label's notch as a flat band along the top edge, which takes the corner curve with it at any radius from 8\nup. `AppInputBorder` clips the notch region and paints the full superellipse through it, as Flutter's own\n`OutlineInputBorder` does by shortening the arc. It needs no `contentPadding` compensation.\n\nNested radii are derived, never tokens: concentric corners need `outer = inner + inset`, so name the inset and compute\nthe outer value, as `_cardRadius` in `lib/ui/popup.dart` does. Adding a token for one of these is how a scale grows without\nbound. A card that also stands on its own elsewhere keeps its token when nested: the proxy node cards inside a proxies\nlist group stay `lg` like those in the tab view, since deriving them from the group card's `xl` left them at 14, smaller\nthan every other card in the app.\n\nA component the scale does not fit keeps its own value instead of growing the scale: name it once in the file and pass\nit to the `all`/`top`/`vertical` builders, as `_itemShape` in `lib/ui/sidebar.dart` does with 10, between the\nchip-sized `sm` and the near-pill `md` on a 40 tall row. Reach for this only once the table and `fit()` are ruled out.\n\n## Verification\n\n- A behavior change gets a focused widget test covering rendering states, taps, scrolling, and empty/error states,\n  including the exception path of any async action. Run `flutter analyze` and `flutter test test/widgets/` plus the\n  tests of the touched view.\n- The scans in `test/lint/` fail on the common UI mistakes and run as part of `flutter test`: an `IconButton` whose\n  icon is an icon needs a `tooltip`; a field that constructs its own controller, notifier, or focus node is released in\n  the same file; a public declaration nothing references is dead. `.agents/rules.md` explains each rule and its\n  allowlist.\n\n## Pitfalls\n\n- Do not mutate provider/domain state merely to smooth a transition. Keep presentation holds local and let real errors\n  bypass them immediately.\n- Do not leave loading animations active when callbacks throw.\n- Do not manually edit generated localization or provider files.\n",".agents/architecture.md":"# Architecture\n\n## Core Integration\n\nThe Go proxy core in `core/` operates in two modes.\n\nAndroid lib mode:\n\n- `gomobile bind` builds `core/mobile` into an AAR: a native library per ABI, shipped as `libclash.so`, and the Java\n  classes gobind generates in `com.follow.clash.core.mobile`. The `:core` Gradle module builds it and takes in its\n  classes and libraries (see Android Core Build).\n- The Android `:core` module owns the binding: `Core.kt` is the only caller of the generated `Mobile` class. Flutter\n  crosses MethodChannels through `lib/plugins/service.dart` and Android's `ServicePlugin` rather than calling the Core\n  directly: `${packageName}/service` for lifecycle commands and Core events, `${packageName}/core` for Core method\n  calls.\n- Flutter runs the UI isolate on Android's main thread (3.47 merges the two and ignores the opt-out in release), so\n  main-thread work anywhere in the process, the Core's included, costs frames. Only sending to Dart needs that thread;\n  a `MethodChannel.Result` may be answered from any thread. `${packageName}/core` is handled on a serial background\n  task queue and answered from the Core's goroutine, an event batch is encoded on the Core's thread and only sent on\n  the main one, `ForceGC` (called from `onLowMemory`/`onTrimMemory`) collects on its own goroutine, and\n  `NetworkObserver` refreshes DNS on its own looper.\n- `lib/core/lib.dart` (`CoreLib`) implements the shared Core interface, gates method calls on its connection completer,\n  initializes and synchronizes Android shared state, and closes the native service path exactly once.\n- Because Core is in the application process on Android, application RSS already includes Core memory.\n\nDesktop core mode:\n\n- Go core runs as a separate process with `CGO_ENABLED=0`.\n- `rust_api`'s `ipc` module provides the native local-IPC primitives: a Unix domain socket on macOS/Linux and a named pipe\n  on Windows, served by an `IpcServer` handle. Dart owns the transport state, RPC correlation, process ownership, and\n  lifecycle convergence above those primitives.\n- `lib/core/service.dart` (`CoreService`) is the composition root. It wires the IPC transport, launcher selection,\n  lifecycle controller, RPC client, and crash-event bridge, and holds no lifecycle logic of its own.\n- `lib/core/desktop/transport.dart` binds an `IpcServer` and turns its events into ready, connected, disconnected, and\n  failed transport events plus a frame stream. A connection that ends on an I/O error is a disconnect carrying that\n  error; failed means the server itself is gone and is terminal for that transport. A replaceable binding keeps RPC\n  subscriptions stable when a failed or stale transport must be rebuilt.\n- `lib/core/desktop/rpc_client.dart` owns request IDs and pending completers, waits up to 10 seconds for a connection,\n  applies a three-minute default method timeout, unwraps `CoreMethodResponse`, and fails all pending calls when transport\n  disconnects or closes.\n- `lib/core/desktop/lifecycle.dart` serializes process intents and owns the authoritative desktop state machine.\n- `lib/core/desktop/launcher.dart` abstracts direct child-process and privileged Helper ownership through idempotent process\n  leases. `lib/core/desktop/helper_client.dart` is the typed local HTTP client for the privileged Helper.\n\n`lib/core/controller.dart` (`CoreController`) selects the implementation based on platform. `lib/core/interface.dart` defines the shared `CoreHandlerInterface`.\n\nKey Go core files:\n\n- `core/protocol.go`: method names, event types, and the parameter and result shapes shared with Dart.\n- `core/method.go`: MethodChannel-style method-call dispatch, the handler table, and response envelopes.\n- `core/lifecycle.go`, `config.go`, `proxy.go`, `delay.go`, `provider.go`, `geo.go`, `connection.go`, `log.go`: the\n  handler functions, one file per area. `core/hooks.go` wires mihomo's hooks to them.\n- `core/message.go`: non-blocking state/priority/bulk event queues and bounded message batching.\n- `core/lib.go`: the Android library API: TUN callbacks, the event listener, and the entry points `core/mobile` binds.\n- `core/mobile/`: the package gomobile binds. gobind generates Java for its exported API, so it speaks only `int32`,\n  `bool`, `string`, `[]byte` and its own interfaces; a Go `int` would become a Java `long`.\n- `core/cmd/core/`: the desktop entry point. `core/` itself is the library package `core`, because gomobile cannot bind\n  a `main` package.\n- `core/server.go`: desktop socket/named-pipe client and framed message forwarding.\n\n## DNS, NTP and Sniffer Overrides\n\nA URL or file profile brings its own sections; a custom profile brings none. The app's DNS page\n(`PatchClashConfig.dns` with `dnsOverrideKeys`) is written over the DNS of every URL and file profile, and offers only\n`DnsOverrideKey.normalProfileKeys`: keys that name nothing of a profile's own, because a subscription's groups and rule\nsets change with every update. A custom profile keeps all three sections in `Profile.overrides`, a `ProfileOverrides`\nstored as JSON in the `profiles.overrides` column, with every key available, and NTP and sniffer exist nowhere else.\n`setupState` resolves `SetupState.overrides` by profile type, intersecting the app-wide keys with the normal set, and\n`_makeRealProfileTask` merges whatever it is handed; there is no on/off toggle, a picked key is the override.\n\nEach `*OverrideKey` names one Clash key, with `parent.field` paths for nested maps, and covers every yaml key of the\nmatching mihomo raw struct; `test/models/{dns,ntp,sniffer}_override_test.dart` read `core/Clash.Meta/config/config.go`\nand fail when a core bump adds or drops one. `overrideJson` builds the fragment in model order and skips a key whose\nvalue is empty, so a key left without a value writes nothing: the core refuses an empty `nameserver` outright.\n`mergeDnsOverride` merges one level deep so a partial fallback filter keeps the profile's other filter keys; NTP spreads\nover the profile's flat `ntp` map. For a profile whose `dns.enable` is not true, the task first fills `baselineDns` for\n`baselineDnsOverrideKeys` (`enable`, `enhanced-mode: fake-ip`, `nameserver`), the one thing the core is never without:\nwith DNS off, mihomo answers every TUN-hijacked query with an error. The baseline stays apart from the model defaults,\nwhich carry no preset of the app.\n\nAn added key starts from the spec's `defaults` through its `OverrideField.reset`, and those defaults are mihomo's: a key\nstarts at the value mihomo parses without it, presets included, such as the nameservers, fake-IP range and filter, and\nNTP server of `DefaultRawConfig`. A key mihomo leaves unset, such as `listen` or a sniff protocol's ports, starts empty\nand writes nothing until the user fills it in, and so does the fallback filter's GeoIP code, whose value is left for the\nuser to choose. mihomo sniffs a protocol listed without ports on that protocol's standard port, which the page shows as\nthe default. The tests above pin the defaults against `DefaultRawConfig`. A spec that mixes in `OverrideQuickEdit` gets\nquick edit, which opens `overrideYaml` in an `EditorPage` and rebuilds the key set from the document through\n`applyOverrideYaml`, rejecting keys outside the section and values the model cannot hold.\n\nmihomo upper-cases sniff protocol names and ignores the deprecated `sniffing` list once `sniff` holds anything, so\n`mergeSnifferOverride` drops the profile's entry for an overridden protocol in any case and first carries `sniffing`\ninto `sniff`. A `rule-set:` key of a custom profile's DNS policy counts as a use of that rule provider, so\n`_resolveInjectedProviders` injects it like one a rule names. Safe mode forces `write-to-system` off on whatever `ntp`\nsection reaches the core, because setting the system clock is a host change; `loadConfig` in the Go wrapper also drops\nit on Android, where the seccomp policy kills the process for `settimeofday`.\n\nTUN is the exception to covering the raw struct. The network settings own only `Tun.coreJson` (`enable`, `device`,\n`stack`, and `auto-route` always on), spread last over the profile's section and patched again on every\n`updateConfig`, so `TunOverrideKey` offers only keys outside it that work in FlClash's TUN. Its keys exist app-wide\n(`PatchClashConfig.tunOverride` with `tunOverrideKeys`, listed in the network page's TUN section as `OverrideEntry`\nrows and added from its last row, as a custom group's options are) for every profile, and per custom profile, whose own keys `ProfileOverrides.overAppTun` lays over the app-wide ones. MTU,\ncongestion control, strict route and the route lists are such keys on desktop, so a profile's own values apply unless\npicked; Android's TUN rows keep them in `Tun`, which its VPN service starts from, and a config saved before the move\ncarries values changed from their defaults into picked keys. `bypassPrivateRoute` stays a network setting: the task\nappends the private ranges to whatever `route-exclude-address` results. `dns-hijack` is a key too: the task writes\n`any:53` beneath the overrides, so a profile's own value never applies, a picked key replaces it, and the key starts at\nthat value. A key sing-tun reads on some platforms only is listed only there\n(`_isRead` in `lib/views/config/tun.dart`): strict route on Linux and Windows, GSO and the interface, user ID and port\nfilters on Linux. `udp-timeout` reaches only the gvisor and system stacks and congestion control only mips, which their\ndescriptions say. Route address sets are left out: sing-tun applies them only through `auto-redirect`.\n`test/models/tun_override_test.dart` fails if a key comes to overlap `Tun.coreJson`. Android starts its VPN from\n`core/tun` without the section, so TUN is listed on desktop only. Being a part of the section, the page has no quick\nedit; see Quick Edit.\n\nA custom profile's overview keeps rows for rules, providers and DNS, which every custom profile runs on, and puts the\ndialer proxies, NTP, sniffer and TUN behind a single \"more\" row, which opens a page listing them with their states.\nBoth lists are the same grouped rows, glyph, title and state, ending in a chevron. Another rarely changed section joins\nthe \"more\" list rather than taking a row of its own.\n\nEvery page is an `OverrideView` over an `OverrideSpec` from `lib/views/config/override.dart`. The spec supplies the\nsection's keys, labels and fields; its `OverrideTarget` is where the model and keys live, `PatchOverrideTarget` for the\napp-wide DNS and `ProfileOverrideTarget` for a profile. Another overridable section is a spec, not a copy of a page.\n\nA config from a release that still had the `overrideDns` toggle keeps, through `Config.fromJson`, the normal keys of\nan on toggle and none of an off one. That release had no custom profiles, so its app-wide NTP override is dropped.\n\n## Listener Exposure\n\n`_makeRealProfileTask` in `lib/common/task.dart` writes `mixed-port`, `allow-lan`, and `external-controller` into every\ngenerated profile. With `allow-lan` off, mihomo's `genAddr` binds each listener to `127.0.0.1`; the external controller is\n`ExternalControllerStatus.close` by default and never binds anywhere but loopback. The task also clears\n`external-controller-tls`, `-unix` and `-pipe` in every mode: the unix and pipe controllers answer without a secret, and\nthe unix one unlinks whatever file sits at its path before opening it to everyone, so a profile may not choose them.\nOutside safe mode, profiles retain `listeners`, `tunnels`, `ss-config`, `vmess-config`, `tuic-server`, `iptables`,\n`dns.listen` and `ntp.write-to-system`. Extra inbounds can bypass the app's LAN and authentication settings, and a TUN\nlistener can change routing independently of the app's TUN toggle. These network and clock effects are an accepted\nboundary; they are not treated as arbitrary filesystem modification. The task disables `iptables` when its\n`inbound-interface` contains a space, because mihomo interpolates it into commands and splits their arguments on spaces.\nSafe mode clears these extra inbounds and iptables, closes DNS listening and disables NTP system time writes even after\nlocal overrides. The editor omits disabled alternate controllers and keeps listener completion.\n\nAn open external controller always has a secret, because mihomo serves a controller with an empty one to every local\nuser and, through CORS, to any page the user visits. `PatchClashConfig.secret` is that secret and it belongs to FlClash,\nas the address does: the task writes it over whatever the profile carries, and `UpdateParams.secret` hands the same value\nto a running Core, which restarts the controller when it changes. `ensureControllerSecret` keeps the pair consistent\n(an open controller gets a generated secret, a closed one drops it, so every opening rotates it) and runs where a\nconfig enters the app: the settings toggle, startup, and `writeConfig` for a restored backup. `_effectivePatchConfig`\nreports a controller without a secret as closed, and `_makeRealProfileTask` independently closes it without a secret\nor in safe mode, so a writer that skips `ensureControllerSecret` opens nothing. The settings page shows the secret in\nan editable row, which refuses an empty value.\n\nThe loopback listener takes local connections without authentication by default: mandatory credentials would lock out\nevery external local consumer a proxy client exists for. The binding only guarantees traffic originates on the device;\nit does not isolate programs already running there, so on Android any app holding `INTERNET` can use the proxy and learn\nthe outbound IP (#1934). The opt-in answer is the local authentication setting (`NetworkProps.authentication`), which spans\nfour paths that must stay in sync: `_makeRealProfileTask` writes the credentials into `authentication` and force-clears\n`skip-auth-prefixes` so a profile cannot silently exempt loopback; `UpdateParams.authentication` applies the same list\nto a running core; `FlClashHttpOverrides` sends the credentials with the app's own proxied requests; and `sharedState`\nwithholds the Android VPN system proxy declaration because `ProxyInfo` cannot carry credentials (TUN still captures that\ntraffic). Desktop system proxy is deliberately not gated — dropping it would leak traffic direct, so the setting's\ndescription tells users to supply credentials manually instead.\n\n### Local Trust Boundary\n\nThe Windows Helper answers only a connection whose client process runs under the owner SID it was installed for;\nthe Linux Helper only the owner uid. A setuid Core is executable by `root:admin` (macOS) or the installing user's primary\ngroup, so its boundary also includes every member of that group, not just the installing account. Profiles cannot choose\na controller address, a secret or alternate controllers. Another account outside those permissions, a subscription,\nor a remote party reaching the privileged Core's control or filesystem APIs is a vulnerability.\n\nA process running as the same account is not stopped. It can ask the Helper to start the Core\nagainst a socket or pipe it owns, or run a setuid Core against a port it listens on, and so become the host that drives\na privileged Core: `initClash` chooses the home directory every path check anchors on, and `getConfig`,\n`getProfileBase`, `getProfileOutbounds` and a `setupConfig` or `buildProfile` base read any path.\nThe Core connects to whatever address it is given and cannot tell that host from FlClash. An account check cannot\ndistinguish it from a same-account process that can already read FlClash's memory and files. This remains a user-to-root\nor user-to-SYSTEM privilege escalation, not a complete security fix. An open external\ncontroller is inside the same boundary: its secret is stored under the account, and `/upgrade` stays mounted (it fetches\nfixed MetaCubeX release URLs, not caller-chosen code). mihomo's `IsSafePath` compares paths lexically, so a symlink inside\nthe home directory escapes it; that fix belongs upstream, not in the submodule.\n\nClosing the same-account case requires limiting what a hijacked Core can do. Keeping the main Core unprivileged and\nmoving host changes into narrow platform helpers requires architectural work, but need not introduce repeated prompts\nor remove ordinary proxy functionality. Features that require additional privileges, such as identifying other users'\nprocesses, need platform-specific handling. Descriptor-based filesystem confinement can preserve ordinary managed\npaths; intentional external symlink targets need explicit allowed roots. These changes have not been implemented.\nAccount authentication, a private socket directory or a symlink check at configuration time does not remove the\nprivileged filesystem operations available to the host. Changes must not widen access to those operations.\n\n### Interface Name Modes\n\nThe same task also writes `interface-name` per `PatchClashConfig.interfaceNameMode`: `clear` (default) forces it empty so a\nsubscription's value cannot survive into the generated profile, `follow` leaves whatever the profile YAML already has, and\n`custom` writes the user's `interfaceName`. mihomo ignores `interface-name` on Android whenever `dialer.DefaultSocketHook`\nis installed (`core/lib.go` `installHooks`, vendored `dialer.go`), so `NetworkListView` in `lib/views/config/network.dart`\nshows the mode picker and text field only on desktop.\n\n### Network Settings\n\n`Tun` holds what Android's VPN service starts from: the switch, device and stack, `mtu`, the mips stack's\n`congestion-controller` and the route lists. The desktop TUN takes only `Tun.coreJson` from it, written over the\nprofile's `tun` section and patched on every update through `tunSchema`; MTU, congestion control, strict route and the\nroute lists reach it as TUN override keys (see DNS, NTP and Sniffer Overrides). `test/models/tun_test.dart` keeps every\n`Tun` key within mihomo's `RawTun` and `coreJson` equal to the keys of `tunSchema`.\n\n`NetworkProps.bypassPrivateRoute` adds `privateRouteAddress` to the excluded routes, through `TunExt.excludedRoutes` on\nAndroid and `_makeRealProfileTask` on desktop. VpnService can only add routes before Android 13, so\n`Tun.vpnRouteAddress` cuts the exclusions out of the routes with `subtractCidrs` and lists every family it routes;\n`VpnService.addRoutes` routes everything only for an empty list. A config saved with the old `routeMode` keeps its\nchoice: `bypassPrivate`, which ignored the route list, loads with the flag on and the list cleared, and `config` with\nthe flag off. No stack in the vendored sing-tun reads `endpoint-independent-nat`, so it is not offered. Android builds\nits TUN from the VpnService descriptor in `core/tun/tun.go`, so only MTU and the congestion controller reach it,\nthrough `VpnOptions` and the `options` argument of `startTun`; the VpnService builder takes the same MTU.\n\nThe TUN fields start at mihomo's own defaults, and a saved zero MTU or empty congestion controller, which mihomo reads\nas those defaults, decodes to them. The outbound rows are plain settings written over every profile, and changing one\nre-applies the profile; keep-alive is forced off on Android, and only Linux honours `routing-mark`.\n\n## Lifecycle Ownership And Convergence\n\n### Shared Flutter Layer\n\n`CoreController.start()`, `restart()`, `stop()`, and `close()` are the only shared lifecycle facade. `close()` is terminal;\ncallers must not try to reuse a closed platform implementation.\n\n`CoreAction` in `lib/providers/actions/core.dart` owns the user-facing Core status and setup sequence:\n\n- `startCore()` publishes `connecting`, starts the platform Core, publishes `connected`, then initializes Core state. A\n  startup error publishes `disconnected` and displays the error.\n- `restartCore()` coalesces overlapping callers behind one worker. `_requestedRestartRevision` records newer requests.\n  After the lifecycle restart, the worker reapplies running state or the profile, rereading `isStartProvider` on every\n  pass, until it has consumed the latest revision, so the newest start intent wins.\n- The provider is an orchestration and presentation layer, not a process owner. Platform lifecycle code remains responsible\n  for determining whether a Core process/service is actually running.\n\nApplication exit is centralized in `SystemAction` and `SystemExitCoordinator`:\n\n1. Optionally save config and clean up DNS, system proxy, and tray resources in parallel.\n2. Close the desktop window.\n3. Call terminal `CoreController.close()`.\n4. Exit the application exactly once.\n\nThe coordinator is idempotent, continues later cleanup steps after an earlier error, preserves the first error for the\ncaller, and uses a three-second watchdog as an emergency application-exit path. `Application.dispose()` and\n`CoreManager.onCrash()` do not independently destroy Core; this avoids competing shutdown owners.\n\n### Applying Setting Changes\n\nA changed setting is patched live through `UpdateParams`, re-applied with the profile, or, for the Android VPN, left\nto a restart the user confirms. [setting-changes.md](setting-changes.md) says which path each setting takes and how to\nchoose one for a new setting.\n\n### Desktop Lifecycle\n\n`DesktopCoreLifecycle` is a latest-desired-intent reconciler, not a queue that blindly executes every request:\n\n- Public intents receive monotonically increasing revisions and target running, restarted, stopped, or closed.\n- Observable phases are `idle`, `starting`, `running`, `stopping`, `failed`, and `closed`.\n- A completed command reports `applied`, `coalesced`, or `superseded`, allowing callers and tests to distinguish a command\n  that won from one satisfied or replaced by a newer compatible intent.\n- Startup opens or replaces the IPC transport, resolves a launcher, generates a 128-bit lowercase hexadecimal session ID,\n  launches Core, and waits for the matching connection. Windows additionally verifies that the named-pipe peer PID equals\n  the process PID returned by the Helper lease.\n- Each running session retains its process owner, lease, PID, session ID, and transport connection generation. Stop waits\n  for both process-exit confirmation and the matching disconnect generation; a missing disconnect replaces the transport\n  before later starts.\n- An unconfirmed process exit is retained as an unconfirmed lease. New start/restart intents fail until ownership can be\n  cleaned up, preventing two Core instances from being treated as the active session. Terminal close may continue on a\n  best-effort basis because the application is exiting.\n- An unexpected disconnect or transport failure while running is converted to `DesktopCoreFailure`, the owned process is\n  cleaned up, and `CoreService` emits a Core crash event for the normal UI recovery path.\n\nDirect launch is used on macOS, inside an AppImage, and as the Windows/Linux fallback when the privileged Helper is not\nready. When the Helper is ready, it owns the Core child and Dart owns it through a session-scoped lease.\n\n### Android Service Lifecycle\n\nAndroid deliberately keeps Flutter requests optimistic and the native layer authoritative:\n\n- `ServicePlugin.start()` and `stop()` acknowledge immediately after submitting intent. They do not wait for service\n  creation, VPN permission, binding, TUN establishment, or teardown.\n- `ServiceState` owns the latest `RunRequest` and the shared configuration. Identity checks discard obsolete work.\n  `startPreparationLock` serializes permission/setup preparation and `transitionLock` serializes actual service\n  transitions.\n- `RunState` is derived, never assigned by a step: `ServiceStateMachine.publish` computes it from the transition in\n  flight, the latest intent, and `ServiceController.runtime`. A service that is up under a stop intent reads `STOPPING`, a\n  start still waiting on a permission reads `STOPPED`, and `STARTED` needs both a running intent and a runtime. The tile\n  and `getRunTime` both read this one value, so they cannot disagree with the service.\n- `ServiceController` owns exactly one `ManagedServiceBinding`, selects `VpnService` or `ProxyService` from `VpnOptions`,\n  binds with a five-second connection timeout, and invokes `ManagedService.start()`/`stop()` off the main thread. The\n  binding and `runtime` (which service runs, and since when) are set and cleared together, by a stop, a failed start, or\n  a lost connection and by nothing else. Flutter's `shutdown` only clears the Core event listener: Android keeps an\n  established `VpnService` alive through its own connection, so a binding dropped on exit left a tunnel that was\n  reported as running but that neither a stop nor a revoke could reach.\n- The Core listens exactly while `runtime` is set: a successful start ends in `Core.startListener()` and every path\n  that clears `runtime` calls `Core.stopListener()`. Both return at once when the Core is already there, which is the\n  usual case, since `startTun`/`stopTun` and Flutter's own `startListener`/`stopListener` got there first. They matter\n  when nothing else does: a proxy-only run stopped from the tile used to keep the mixed port open with nothing\n  reported as running. A start closes after itself the same way: Flutter's `startListener` and the native `quickSetup`\n  both open the ports before any service exists, so a start that ends without a `runtime`, refused or cancelled, stops\n  the empty service once more, without passing through `STOPPING`. Flutter may be gone by then and never see the\n  `stopped` report.\n- A start that does not bring the service up gives its intent back and takes down whatever it left running, unless a\n  newer request has claimed the service. A failed start therefore always ends in `STOPPED`, whichever step refused it.\n  The one start that mints no request is a repeat on a settled run of the same kind, which Flutter sends every time it\n  reattaches: a request refused at a permission prompt there would stop a service that was running fine.\n- Generic service creation/destruction is lifecycle evidence, not user intent. New commands must flow through\n  `ServiceState.requestStart()`/`requestStop()` or the explicit system-action handlers instead of inferring intent from a\n  callback.\n\nQuick Settings, notification, revoke, and Always-on VPN paths converge on the same owner:\n\n- With a Flutter engine attached, `ServiceState.handleStartAction()` forwards through `TilePlugin` to `TileManager`,\n  because only Flutter can apply the current profile. Without Flutter, native code claims the intent, restores\n  `SharedState` from preferences, runs `quickSetup`, checks VPN permission, and runs the start; claiming first is what\n  makes a second action during the setup toggle that start off instead of racing a second setup against it.\n- An engine that attaches to a run the service already has, started that way or outliving the last engine, adopts it:\n  `SetupAction.initStatus` restores the md5 of the config.yaml the Core last accepted and pushes the profile only when it\n  now builds differently. Pushing the same config again rebuilds every proxy group, and mihomo keeps a url-test group's\n  unmeasured pick for ten seconds, so the dashboard's exit and service checks ran a second time once it moved.\n- A stop never needs Flutter. `handleStopAction()` stops the service natively whether or not an engine is attached, so\n  the tile, the notification action and a revoke work even when Flutter's own idea of the state is wrong.\n- Whenever the run ends without Flutter having asked for it (a start that failed, a native stop, a revoke, a lost\n  service), `ServicePlugin` sends `stopped` with the revision of the last `start`/`stop` command it received.\n  `Service` in `lib/plugins/service.dart` drops a report older than its newest command, or one that arrives while its\n  last command was a stop (on-demand suspension stops the service while `isStartProvider` stays true), and\n  `AndroidManager` answers the rest with `setRunning(false)`. The report is read on the platform thread, where the\n  commands arrive, so the revision and the intent it carries describe the same instant.\n- Android may create an Always-on `VpnService` through `onStartCommand()` without FlClash's bound-service path. The service\n  sends the explicit, permission-protected `VPN_START_REQUESTED` broadcast to `ServiceBroadcastReceiver`, which routes it to\n  `ServiceState.handleStartAction()` so Core/configuration and the normal binding are restored before TUN is treated as\n  ready.\n- `VpnService.onRevoke()` stops TUN/modules first, then sends `VPN_REVOKED`; the receiver only requests a stop while\n  `ServiceController.runtime` is a VPN run.\n- `ServiceBroadcastReceiver` uses `goAsync()` and an atomic one-shot completion. Normal completion or a nine-second\n  watchdog calls `PendingResult.finish()` exactly once; the watchdog releases Android's broadcast lease and does not\n  cancel or redefine the underlying lifecycle intent.\n\n## Core Protocol And Event Delivery\n\nThe shared protocol uses `CoreMethodCall(id, method, arguments)` and `CoreMethodResponse(id, result, error)` in both\ndirections. The envelope is the only JSON serialization layer: keep arguments, results, and event data as structured JSON\nvalues rather than embedding pre-encoded JSON strings. Plain domain strings, such as country codes or provider contents,\nremain strings.\n\nA response over 1 MiB goes to a file under `<home>/responses/`, and the envelope carries only its `file`\n(`core/spill.go`). Sent inline, Android copies it into the Java heap three times (the gomobile `byte[]`, the codec and a\ndirect buffer), and desktop IPC drops a frame above `maxIPCFrameSize`. `lib/core/spill.dart` reads a spilled file only\nfrom that directory, decodes it in an isolate and deletes it; `initClash` clears what an earlier run left.\n\nA profile file's proxies, groups and rules never cross the bridge. `getProfileBase` returns the config without\n`proxies`, `proxy-groups`, `sub-rules` and `rules`, with their counts, the profile's MATCH rules and a sha256 digest.\n`SetupAction.getProfile` patches it as before and sends the result as `SetupParams.base.overlay`. `core/profile_base.go`\nputs the profile's bulk sections into the overlay, appends the profile's rules after the overlay's, writes `config.yaml`\nfor the Android service and the controller's reload, and parses it. `config.yaml` is encoded a slice at a time, because on a\nprofile of 100k proxies and 2M rules one `Encode` call doubles the peak heap. The preview reads the YAML `buildProfile`\nmerges into the spill directory, and the extend page only the names\nand types `getProfileOutbounds` lists; a script extension still needs the whole config from `getConfig`.\n\nGo event delivery is intentionally non-blocking:\n\n- Loaded-provider, geo-update, and route-changed events use a state queue that never evicts; the rules for it are under\n  Core API Safety in `.agents/rules.md`. Every other event except the bulk kinds, delay included, uses a 256-entry\n  priority queue. Desktop process crashes are generated locally by `CoreService` from lifecycle failures rather than\n  sent through the Go queue.\n- High-volume log, request/connection, and DNS query events use a separate 256-entry bulk queue, so bulk floods cannot\n  evict state.\n- A full priority or bulk queue evicts only its own oldest event and retries the newest; DNS events never evict. Core\n  work never blocks on event delivery.\n- The batcher flushes at 32 messages or every 16 milliseconds. Priority events are preferred, but one bulk opportunity is\n  guaranteed after eight priority messages to prevent starvation.\n- `sendMessage` drops an event before it is queued when `hasEventListener` is false. That only happens on Android, after\n  the Flutter engine detaches while the service keeps the Core running; request and DNS events would otherwise be\n  encoded per connection and per query for nobody.\n\nDNS query events come from the Clash.Meta fork's `dns/patch.go` and are raised where mihomo writes its `[DNS]` debug\nlog, so the DNS page lists the same resolutions: one event per cache hit, and one per upstream exchange however many\ncallers singleflight merged into it, carrying the upstream whose answer was used. Fake-IP and hosts answers never reach\na resolver and are not recorded. Queries from apps arrive with the `DNSContext` that `Service.ServeMsg` created and\nreport `app`; other initiators come from `resolver.WithInitiator` at the call site (rule matching, DIRECT, proxy\nserver dialing), and unlabeled queries report `other`.\n\nDialer-loop events come from the fork's `component/proxydialer/patch.go`. mihomo follows a `dialer-proxy` that leads\nback to its own proxy until the stack overflows, so a dial that asks the same `dialer-proxy` for an address it is\nalready dialing through it fails with `ErrDialerLoop`. `core/hooks.go` reports each looping name at most once every\nthree seconds as a priority event, which the app shows as an error notice whether or not logs are captured. The custom\nprofile pages flag only the loops FlClash can follow on its own, through a group's own list or an `include-all-proxies`\nthat no filter, exclude filter or exclude type narrows; what filters and providers pick, including provider proxies\nthat carry their own `dialer-proxy`, is left to this guard.\n\n`ProxyGroup.fromJson` reads `include-all` as both `include-all-proxies` and `include-all-providers`, as the core does, so\nimport and quick edit keep it. They take in only what the config declares, which is what some group of the profile\nnames: its local proxies and the file or url profiles in a `use`. Setting one declares nothing more, so the app's other\nproxies and subscriptions stay out, and the core picks from the declared ones by the group's filters. Groups saved by\nv0.9.0 kept `include-all-proxies` in their stored `definition`, and it takes effect again.\n\nDesktop RPC accepts both a single event object and batched event lists. Android and desktop listener dispatch isolate\nlistener exceptions so one faulty observer does not prevent the remaining events/listeners from running.\n\n## User-Facing Core And Delay Feedback\n\n`CoreStatusButton` in `lib/views/dashboard/widgets/core_status_button.dart` is the desktop dashboard's status/restart\nsurface. It is shown only outside dashboard edit mode and only when `coreLib == null`:\n\n- Provider state remains authoritative. The widget keeps a separate display-only status so a fast\n  `connecting -> connected` transition still shows at least 600 milliseconds of progress instead of flashing.\n- The hold arms only after an observed transition to `connecting`; mounting while already connecting does not invent a new\n  delay. A real `disconnected` transition cancels the hold immediately so failure is never hidden, while a long-running\n  connecting state remains visible after the timer expires.\n- Taps during the display hold or while the provider is genuinely connecting are inert. Connected/disconnected taps show\n  the appropriate confirmation and delegate restart to `CoreAction`; the widget never starts Core directly.\n\nProxy delay testing follows the same failure-safe UI rule. `ProxiesAction._runDelayTests` marks its keys in\n`pendingDelayTestsProvider` and releases them in `finally`, and a test the Core does not answer leaves the last\nmeasurement in place; the delay-test bullets under Core API Safety in `.agents/rules.md` give the reasons. A key stays\n`queued` until the `TaskPool` actually runs it, so node cards animate a loader for at most `maxConcurrentDelayTests`\nnodes and show a static glyph for the rest; a loader on every queued node made fast scrolling drop frames. A group's delay\ntest button, the tab page's action and the list page's group header alike, spins while `delayTestingGroupsProvider`\nholds the group; `delayTestPageGroup` marks it, ignores a second request for the same group, and clears it in `finally`,\nso an RPC failure cannot leave the button spinning and remounting a header does not lose the state.\n\nThe dashboard's probe cards show a failure as still checking for five seconds after they see `proxied` turn true,\nthrough `ProbeStartHold` in `lib/views/dashboard/probe_start_hold.dart`: `ServiceStatusCard` holds the check on each\ntile, and `CurrentOutboundCard` holds the exit IP, whose refresh button stays inert meanwhile.\n`SetupAction.setRunning` flips the run time, and with it `proxied`, before the Core has brought its listeners up, so the\nprobe launched on the spot often fails, and the config re-apply that follows every start probes again moments later;\nwithout the hold a card flashes a failure and then takes it back. The hold is local display state like the connecting\nhold above: the cache still records the failure, the hold arms only on an observed transition, a result that is not a\nfailure shows at once, a stop cancels it, and a failure still standing when it ends is shown.\n\n## Route Consistency\n\nA probe result on the dashboard — the exit IP, a node's exit IP, a service check — either describes the route in\nforce or is being asked again. The mechanism has three parts.\n\n- The Core is the source of truth for the route. `core/route.go` keeps two counters. The epoch is a reset, which\n  no answer outlives: a config apply, an IPv6 switch, and a start (the listeners coming up and, on Android, the TUN\n  after them). The route version is a move, which may send a host to another exit: a Selector, URLTest or Fallback\n  pick changing, a proxy provider whose version changed, a rule set that loaded new content, a GeoIP or GeoSite\n  update, and a mode switch. `handleChangeProxy` re-reads the picks under `selectMu` and answers\n  `ChangeProxyResult{changed}`, so the connection reset only runs when the pick moved. A health check moving a\n  URLTest pick has no mihomo hook, so the picks are polled every second while the host watches (`watchRoute(true)`)\n  and the listeners are up; the same poll notices a provider or rule set that updated in the background, which in\n  this fork includes every provider an apply loads, since `loadProvider` returns before they finish. Every probe answer\n  carries the counters it started under and its hits: each host it dialed, the proxy it was pinned to, and the exit\n  it left through, read off the tracked chain or, for a connection that never reached the tracker, matched again.\n  An exit is the last hop with its server address, or a load balancer, which spreads connections without the route\n  moving. `checkRoutes` matches those hosts again through `tunnel.ResolveRoute`, a fork patch that runs the rules\n  without dialing, and reports per answer whether every exit held. `routeChanged` publishes the counters whenever\n  they differ from the last read. A start resets the epoch because `isStartProvider` flips before the Core is told,\n  so the probes sent at the tap leave ahead of the TUN routes and over DNS connections `handleStartListener` resets,\n  and an Android start acknowledges before the VPN exists. Outbound IP lookups are cancelled by the first lookup\n  under a newer epoch, which frees their probe slots; lookups under one epoch are left alone, since the routed\n  target while stopped and a pinned `DIRECT` send the same request.\n- `routeTrackerProvider` in `lib/providers/route_state.dart` merges that with the host's side: `proxied` (running and\n  not suspended), a host epoch that a connectivity event moves, and `synced`, which is false until `watchRoute` has\n  returned a snapshot and again after a Core disconnect. Nothing is fresh while unsynced, so a cache never judges\n  answers against counters it made up. The tracker subscribes when the first target is watched and releases the Core\n  when the last one goes; an answer newer than the state means an event was evicted, and the cache asks for a resync.\n  It also releases the Core while the app is hidden and reads it again on return, keeping `synced`: the Core and its\n  counters are the same ones, so the fresh snapshot invalidates only what moved. On Android the Core drops the watch\n  itself when the event listener is cleared, because an engine that goes away never sends `watchRoute(false)`.\n- `RoutedProbe` in `lib/providers/routed_probe.dart` is the one cache both `OutboundIpProbe` and `ServiceStatus` mix\n  in. A widget `watch`es the target it shows and `unwatch`es on the way out; only watched targets are probed, one\n  request in flight per target. An answer is stamped with the Core's counters, its hits and the host state at\n  launch. A reset (`RouteState.hasMovedSince`: the Core epoch, the host epoch or `proxied`) invalidates everything:\n  a stale watched entry shows as checking at once and is asked again after `probeMoveSettleDuration`, since a start\n  moves it three times within about 150 ms (`proxied`, then the listeners, then the Android TUN) and the user is\n  watching for the new address. A new route version keeps every answer on show and, once `probeSettleDuration` has\n  passed the next pick poll, sends their hits to `checkRoutes` in one call; only an answer whose exit moved is\n  dropped and asked again, and one that held takes the checked version. An answer that lands under an older version\n  than the state is held back as checking until its own check returns, so it never shows and then flips. An ask\n  that is not the user's or a reset's waits out `probeThrottleDuration` since the target's last probe, so a config\n  load whose providers, rule sets and url-test picks land over a few seconds moves an exit at most one probe more.\n  A `refresh` waits for neither, and an unwatched entry whose exit moved is dropped. Nothing launches or checks\n  while the app is hidden: what falls due waits for it to be shown.\n  Failures wait for `retry`, `refresh`, or the app resuming (`markResumed`), and\n  a service check that timed out is a failure that still shows its status. `refresh` is the user's own request and\n  runs for unwatched targets too: the service sheet reads the cache and never watches its rows, because one service\n  check is one to four full HTTPS fetches where a delay test is a single empty request.\n\n`ipQuality(ip)` in `lib/providers/ip_quality.dart` is keyed by the outbound IP found, so a route change reaches it as\na new IP and an unchanged IP reuses the result. A lookup races its sources the way the IP check does and takes the\nfirst one that determines a type. A success is saved to the `IpQualities` table and answers for that IP, across\nrestarts, until `ipQualityMaxAge` (three days) has passed; a shown result is asked again when it expires,\nand invalidating the provider (the sheet's check again) skips the saved record. A failure is not saved, and Riverpod's\nautomatic retry is off because a retry after an HTTP 429 spends the same per-outbound-IP quota again.\n\n## UI Layer\n\n`lib/ui/` holds the components and design tokens that know nothing of the app: shapes, surfaces, motion, colors, the\nscaffold, sheets, popups, lists and the glyphs under `lib/ui/icons/`. `lib/widgets/` holds the components built from\nthem that do: settings rows, subscription and profile pieces, anything that reads a provider or a model.\n`widgets/widgets.dart` re-exports `ui/ui.dart`, so app code imports either barrel.\n\n`test/lint/ui_layering_test.dart` walks the imports of every file under `lib/ui`. Nothing they reach may leave\n`lib/ui`, except `lib/common` files whose own imports stay out of the app, never the `common.dart` barrel, and none\nmay import Riverpod. A component that needs app state reads what the app provides above its navigator:\n\n- `ViewScope` (`lib/ui/view_scope.dart`): the window size, whether the layout is mobile, and the window controls\n  over a page. `AppViewScope` in `lib/manager/theme_manager.dart` fills it from `viewSizeProvider`,\n  `isMobileViewProvider` and `versionProvider`. It is an `InheritedModel`, so a reader of one value does not rebuild\n  when another changes; `context.isMobileView` reads it without listening.\n- `MeasureScope` (`lib/ui/measure.dart`): the `Measure` that `ThemeManager` also assigns to `globalState.measure`.\n- The `MediaQuery` text scaler that `ThemeManager` sets: the app text scale behind `.ap(context)`, `.mAp(context)` and\n  the `...Of(context)` paddings in `lib/ui/metrics.dart`. Reading it there makes a widget sized by them rebuild when the\n  scale changes; a global copy or a top-level final would keep the scale it was first read at.\n- `UiLocalizations` (`lib/ui/l10n.dart`): the strings the components show themselves. `AppUiLocalizations.delegate`\n  in `lib/common/app_localizations.dart` serves them from the ARB strings; a new one needs a getter on both.\n\nA test that builds its own `MaterialApp` registers `AppUiLocalizations.delegate` and returns `wrapAppScopes` from\n`test/helpers/test_app.dart` in its builder, as `TestApp` does.\n\n## Icons\n\n`lib/ui/icons/` holds every icon the app shows. Nothing in `lib/` uses the Material icon font, and\n`test/lint/material_icons_test.dart` fails on any `Icons.*` or `AnimatedIcons.*`, so every icon shares one stroke and\ncan animate its shape.\nA `Glyph` is a list of shapes on a 24-unit grid, stroked at `Glyph.strokeWidth` with round caps and joins and\nsuperellipse corners; a curve the primitive shapes cannot draw is a `GlyphPath`, SVG path data traced along the\nstroke's center. Each shape has a role that decides its filled form, the way SF Symbols and Fluent pair an\noutlined icon with its filled variant:\n\n- `body` fills;\n- `detail` is cut out of the fill, and a `solid` detail such as a dot is cut as an area;\n- `line` stays a stroke, for parts that belong to no body, like a handle or a link between nodes; it thickens from\n  `Glyph.strokeWidth` toward 2.2 as the glyph fills, so a glyph of lines alone still carries weight in a filled button.\n  A `solid` line, such as the dots of `AppGlyphs.more`, scales by the same ratio.\n\n`GlyphPainter` paints any point between the two forms. Each body fills inward from its outline, reaching half its\nshorter side, which no point of the body lies beyond, as `fill` reaches 1. Details thin away over the first half and\nreturn as cutouts over the second, so a detail is never a stroke and a cutout at once. The glyph is painted opaque into\none layer that carries the color's alpha: a translucent color, such as a disabled one, then stays even where strokes\ncross the fill, and a cutout clears only the glyph's own pixels. `GlyphIcon` shows a glyph at a fixed `fill`, taking\nits size, color, and fill from `IconTheme` unless it is given its own, as `Icon` does, and mirroring it in right-to-left\ntext when `Glyph.matchTextDirection` is set.\n`AnimatedGlyph` drives a `GlyphIcon` from its `filled` flag and pops briefly when it fills. A glyph whose shape follows\nsome other state, like the sidebar toggle that tracks the pane's width, is a builder in `AppGlyphs` such as\n`AppGlyphs.sidebar(progress)`. A new glyph goes in `AppGlyphs` and in the list `test/icons/glyph_painter_test.dart` runs\nthrough its checks; it is named for what it means in the app, one glyph per meaning, with SF Symbols and Fluent as the\nreference for the metaphor. Tests find one with `find.byGlyph` from `test/helpers/glyph_finders.dart`.\n\nIcons are outlined by default and filled inside a filled button: `IconButton.filled` and `.filledTonal`, and\n`FilledButton` in its tonal and icon forms draw their glyph with `fill: 1`, because an outline on a tinted\ncontainer reads weaker than the container it sits in. A glyph with no body only thickens its lines and still takes\n`fill: 1`, so the rule holds if the glyph later gains one. `test/lint/filled_button_glyphs_test.dart` checks every\n`GlyphIcon` written inside such a button; an icon that reaches the button from elsewhere, as through a helper that picks\nthe button variant, gets its fill by hand or from `IconThemeData.fill`. Everywhere else fill marks state only: the\nselected destination, a pinned window, a lit torch. The phone's floating navigation bar is the one exception: like an iOS\ntab bar it fills every destination and marks the selected one by colour alone.\n\nFramework widgets that draw their own icon take it from the theme: the `ActionIconThemeData` in `lib/application.dart`\nsupplies the back and close buttons, with `AppGlyphs.backFor` picking the platform's back glyph, and a `DropdownMenu`\nis given its chevrons. A framework widget added later that paints a Material icon needs the same treatment.\n\nEmpty states draw no image assets either. `NullStatus` in `lib/ui/null_status.dart` shows the glyph for its\nmeaning filled on a Material 3 Expressive shape from `material_new_shapes`, with a few accent marks, and enters with\none of three choreographies picked at random: the shape spins in, morphs out of a circle, or springs in while the\naccents orbit into place. The glyph takes `onPrimaryContainer`, not `primary`: the `content` scheme variant can leave\n`primaryContainer` as light as `primary` in dark mode.\n\n`CaptionIcon` is the one icon that does not follow the glyph grid: the window caption buttons keep the one pixel,\npixel-aligned stroke of Windows' 10-pixel caption glyphs on every platform.\n\n## App Bar Actions\n\n`CommonScaffold` keeps at most three buttons in sight in its bar, the overflow menu button included. A page hands it\nwhat may fold as data: `primaryAction`, `iconActions`, `menuItems`, and `searchActions` while searching; custom widgets\npassed through `actions` never fold. Past three, two buttons stay beside the overflow menu: the search button when\nthere is one, then foldable actions, primary action first;\neverything else goes into one overflow menu, primary action first, and a folded action that is loading shows as a\ndisabled item. The bar reads as one button group of two beside a single button: the overflow menu button when\nsomething folds, otherwise the last action, so the proxies list shows a search and delay test group beside the menu.\nThe bar holds `primaryAction` everywhere, a phone's home page above the fixed navigation bar included, and nothing in\nthe app floats an action button: a secondary a",".agents/rules.md":"# Rules\n\nThese are repository coding and testing conventions. Codex command permission rules belong in `.codex/rules/*.rules`; see `.agents/agent-config.md` before adding those.\n\n## Dart and Flutter Style\n\nThe lint set lives in `lint_options.yaml` at the repo root. The root `analysis_options.yaml` and every local plugin under\n`plugins/*` include it, so application and plugin code are held to the same rules. Add or change a rule there, not in an\nindividual `analysis_options.yaml`; those files carry only their own `analyzer.exclude` entries.\n\n`lint_options.yaml` enforces these non-default rules:\n\n- `prefer_single_quotes: true`: always use single quotes.\n- `require_trailing_commas: true`: use trailing commas in multi-line argument lists.\n- `sort_child_properties_last: true`: `child:` must be the last named parameter.\n- `avoid_print: true`: do not use `print()` calls.\n- `prefer_const_constructors: true` and `prefer_const_declarations: true`.\n- `prefer_final_locals: true` and `prefer_final_in_for_each: true`.\n- `always_declare_return_types: true`.\n- `only_throw_errors: true`: throw an `Exception` or `Error`, never a bare `String`.\n\nFailures whose whole content is a message meant for the user throw\n`MessageException` from `lib/common/exception.dart`. Its `toString()` is the bare\nmessage, which is what `globalState.safeRun` surfaces in the dialog, and the\nthrow stays catchable as an `Exception` and carries a stack trace. Assert on it\nwith `isA<MessageException>().having((e) => e.message, 'message', ...)`, not on a\nraw string.\n\n### Corner Radius\n\nRounded corners are superellipses (continuous corners) everywhere, never circular arcs; when a component offers\nboth, pick the superellipse. `test/lint/superellipse_corners_test.dart` fails on the circular APIs below in `lib/`. Use\nthe superellipse API at each layer:\n\n- Shapes: `RoundedSuperellipseBorder` instead of `RoundedRectangleBorder`.\n- Clips: `ClipRSuperellipse` instead of `ClipRRect`.\n- Container decorations: `ShapeDecoration(shape: RoundedSuperellipseBorder(...))` instead of\n  `BoxDecoration(borderRadius: ...)`; borders move to the shape's `side`, and a `Container` with\n  `clipBehavior` still clips to the shape path.\n- Canvas: `canvas.drawRSuperellipse(RSuperellipse.fromRectAndRadius(...))` instead of `drawRRect`.\n- Ink: `InkWell.customBorder` with an `AppShape` instead of `InkWell.borderRadius`.\n\nPassing `BorderRadius.circular(x)` as the `borderRadius` argument of these APIs is expected — it only\ncarries the corner magnitude; the rendered geometry stays a superellipse.\n\nPills are no exception. `StadiumBorder` joins its round caps to the straight sides with a curvature jump, while\n`AppShape.full`, a superellipse at full radius, blends them, and on a square it renders the exact circle, so it serves\ncircles too. Material 3 defaults every button, icon button, segmented button, and search bar to `StadiumBorder`;\n`ThemeData.withAppShapes` replaces that for the button themes, and a local `FilledButtonTheme` or `IconButtonTheme`\nreplaces the app's, so merge the ambient `XButtonTheme.of(context).style` into it, as `CommonMinFilledButtonTheme`\ndoes. `CircleBorder` and `BoxShape.circle` are fine for something that is always square.\n\nAPIs that accept only a `Radius`, such as `ScrollbarThemeData.radius`, keep circular corners; supply the superellipse\nwith an enclosing clip or shape where it shows. Framework widgets that paint their own circular corners with no shape or\ndecoration to override are left as Material draws them; switches are `CommonSwitch`, drawn by the app. Tooltips take\ntheirs from `withAppShapes`, which sets `tooltipTheme.decoration`.\n\n### Typography\n\nText sizes follow Apple's iOS Dynamic Type scale at its default size, on every platform. `AppTextStyle` in\n`lib/ui/typography.dart` holds its eleven styles, `largeTitle` to `caption2`, with Apple's leading and weights, plus two\nsteps the scale lacks: `display` for a dashboard figure and `pageTitle` for an app bar. Take one with\n`AppTextStyle.body.of(context)`, pass `emphasized: true` for the scale's emphasized weight (bold for the titles,\nsemibold for the rest), and change color or family with `copyWith`; a weight is either regular or emphasized. Text\nsized to the shape it marks, such as a letter scaled to its icon, comes from `AppTextStyle.custom`. The dashboard's\nroles (`DashboardText` in `lib/views/dashboard/style.dart`) are built on the same types. `ThemeData.withAppTypography`\nmaps Material's `TextTheme` slots onto the scale so Material components follow it; app code never reads those slots.\n\nApp bar titles sit at the leading edge in `pageTitle` on a root page and on any desktop page, at the leading edge in\n`headline` on a pushed page in the mobile view, and in the bar's centre in `headline` on a bottom sheet. A title widget,\nsuch as the editor's name field, stays at the leading edge. Past a back button a title keeps the bar's action inset\nclear of the button on either side.\n\n`test/lint/typography_test.dart` fails on `textTheme` anywhere in `lib/` but `typography.dart`, and on `fontSize:` or\n`fontWeight:` outside `lib/ui/`, except in the code editor, whose text follows the editor font-size setting.\n\n### Text Field Suggestions\n\nOn Android, Flutter maps `enableSuggestions: false` to the visible-password input type, not only to \"no suggestions\"\n(`TextInputPlugin.inputTypeFromTextInputType` in the engine). Input methods then treat the field as a password: no\nclipboard panel, no Chinese layouts, and on some systems a secure keyboard. With the app's clipboard permission denied,\nthat leaves no way to paste a link at all. Only the secret fields, `SecretTextFormField` and `FormRowSecretTextField`,\nask for a password keyboard; a link, key, rule or regex field that should not be corrected sets `autocorrect: false`\nalone. `test/lint/text_field_suggestions_test.dart` fails on `enableSuggestions: false` or\n`TextInputType.visiblePassword` anywhere else in `lib/`.\n\n### Length Units\n\nFlutter lengths are logical pixels, the unit Android calls dp. Comments, test names, docs and reports give them in dp,\nas in `48 dp`, never px: a reader takes px for physical pixels and misjudges the size by the device pixel ratio. Write\n\"physical pixel\" in full where the code really works in them, as the hairline snapping in\n`lib/ui/list_row_separator.dart` and the Win32 frame metrics in `plugins/window` do; raster image sizes in\n`tool/` and CSS under `site/` keep px. `test/lint/length_unit_test.dart` fails on a number followed by px in `lib/`\nand `test/`.\n\nCI gates formatting: `dart format --output=none --set-exit-if-changed lib test\ntool plugins setup.dart` runs before `flutter analyze`.\n\nThese directories are excluded from analysis:\n\n- `build/**`\n- `lib/l10n/intl/**`\n- `lib/**/generated/**`\n- `plugins/**`\n- the platform runner directories: `android/`, `ios/`, `web/`, `windows/`, `macos/`, `linux/`\n\n## Comments\n\nComments are part of the code and some of them are load-bearing. What this repository limits is the useless and the\nfrequent. Every comment that restates the code devalues the ones carrying real information, until readers skim past all\nof them — a file with three comments that matter is more readable than one with thirty.\n\nThe two halves are enforced differently, because only one of them can be counted. Whether a comment is useless is a\njudgment, and it lives in the rules below and in review. Whether comments are too frequent is arithmetic, and\n`comment-density` measures it.\n\n### What a Comment Must Earn\n\n- Write one when it carries something the code cannot: a non-obvious constraint, an upstream or platform behavior being\n  worked around, a reason the next reader would otherwise get wrong, a coupling to a value defined somewhere else.\n- Never restate what the code already says. If the comment and the line below it convey the same thing, the comment is\n  noise.\n- Never narrate. Not what this diff changed, not what the code used to be, not the order you did things in. Version\n  history belongs in git and change rationale belongs in the commit message.\n- Never annotate line by line or statement by statement. A block that seems to need a comment per step needs better\n  names or a smaller decomposition instead — reach for structure before prose.\n- Prefer encoding the intent in structure and naming. A named mixin, type, or method that makes the invariant hard to\n  break beats a paragraph asking the next reader not to break it.\n- Delete commented-out code, stale version notes, and comments that only restate the code, whenever you edit the file\n  that contains them. This does not need approval.\n- These are not comments and are exempt from every rule here, including the density count: analyzer and linter\n  directives (`// ignore:`, `// ignore_for_file:`, `// coverage:ignore`), license and copyright headers,\n  code-generation markers, and comments inside vendored upstream code such as `lib/ui/open_container.dart`.\n\n### Density\n\n`tool/hooks/comment_density.sh` fails a file whose added lines are more than 5% standalone comment lines, ignoring\ndiffs under 20 added lines so small edits are never caught. The number is calibrated on this repository's own history:\nhealthy changes sit at or under 3.6%, while the core fix that prompted the gate ran 22.4%.\n\nIt is a ceiling on frequency, not a target to fill. Do not read `core/`'s inherited mihomo density as a quota either —\nit is forked upstream code, not a house style.\n\nThree gates run the same script, and they do not have equal force. The `PostToolUse` hooks in `.claude/settings.json`\nand `.codex/config.toml` run after the tool and hand offending lines back as feedback; neither can undo the completed\nwrite. The `comment-density` pre-commit hook fails the commit, and it is the only hard gate that covers every tool.\nBehavior is pinned by `tool/hooks/comment_density_test.sh`, which CI runs through `tool/hooks/test.sh`.\n\nWhen a change genuinely warrants more, raise the ceiling for that run rather than working around it:\n`COMMENT_DENSITY_MAX=20 git commit`, or `SKIP=comment-density git commit` to step past it entirely.\n\n### Where Knowledge Belongs\n\nA comment is one of three destinations, and often the weakest. Pick by where the constraint would be violated, not by\nhow important it feels.\n\n- **Assertable behavior goes in a test.** A test is the only form that cannot drift, because it fails when the behavior\n  it describes is broken. Prefer it over both a comment and a document whenever the fact can be checked in code.\n- **Repository-wide defaults, ownership, and invariants go in `.agents/*.md` or a `.agents/skills/*/SKILL.md`.** They\n  are violated from many files, so they must reach every future agent at session start. A comment in one file cannot do\n  that.\n- **A fact that is true only at one call site, and is not visible from that call site, is the one thing a comment does\n  better than a test or a document.** Its value is being in the reader's line of sight at the moment of the edit.\n  `lib/common/constant.dart` carries one: the delay-test concurrency cap is bound to `delayTestConcurrency` in\n  `core/delay.go`, and whoever changes that number must see the constraint on the same screen. This is the case a\n  comment is for; the bar is that no test and no `.agents/` entry could hold the fact instead.\n\nBoth failure directions are real. Moving a local constraint into `.agents/` hides it from the person editing the line;\nleaving a repo-wide policy as a comment reaches only the reader of that one file.\n\n## Core API Safety\n\n- `core/Clash.Meta` is a fork of mihomo, and changes to it are budgeted for features, not repairs. Fixing a bug there is\n  low priority even when the bug is real and the fix is small: every patch is one more thing to carry across an upstream\n  rebase. Solve it on the FlClash side of the boundary and note the mihomo behaviour you are working around. Reach into\n  the submodule only for a feature that has nowhere else to live, or when the problem is one the FlClash patches\n  themselves introduced — and say which of the two it is in the commit message.\n- Do not expose direct filesystem deletion APIs through Core or helper IPC; use\n  a scope-specific cleanup API instead.\n- The desktop IPC socket in `plugins/rust_api` admits a Unix peer only when its effective uid is the app's own or root:\n  the socket lives in `/tmp`, and the Core connects as root whenever it runs setuid for TUN or under the Linux Helper.\n  A launch mode that runs the Core as any other user has to widen `admit` in `ipc/access.rs`, and Windows\n  keeps its identity check on the Dart side, which compares the named-pipe peer PID with the Core it launched.\n- Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, the\n  gomobile binding, and desktop IPC. Do not double-encode `arguments`, `result`, or event batches. A response whose\n  `file` is set was spilled to disk; a transport resolves it with `resolveSpilledResponse` before reading `result`.\n- `core/message.go` carries three event queues, and the split is load-bearing: state (loaded, geo-update,\n  route-changed), priority (every other event), and bulk (log, request, DNS). Priority and bulk evict their own oldest\n  entry under backpressure, except DNS, which enters the bulk queue through `enqueueState` so a lookup burst cannot push\n  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\n  `UpdatingAction` sweeps it as stale minutes later. Do not merge the tiers or give state eviction semantics. `enqueueState` drops silently on a full\n  queue and must stay that way: reaching it means the host stopped reading, which `logDeliveryError` already reports,\n  and reporting it from the message layer feeds the same batcher.\n- Every method of the callback interfaces in `core/mobile` (`TunHandler`, `ResultHandler`) returns an `error`. gobind\n  clears a Java exception and hands it to Go only for a method that declares one; otherwise the exception stays pending\n  and the next JNI call on that thread aborts the process, so a throw in `protect`/`resolveUid`/`resolvePackage`/\n  `onResult` would crash unrelated code. `core/mobile` turns the error into the answer the system gives when it will not\n  help: `false`, `-1`, or an empty package name.\n- `TunHandler`'s hooks in `core/lib.go` check `callbacks` under `th.mu` before every call. `clear` drops the callbacks\n  while connections may still be resolving, and a call through the nil interface panics on a mihomo goroutine, outside\n  every recovery.\n- The Android bridge resolves an owner in two steps — `ResolveUid` then `ResolvePackage` — because mihomo fills\n  `metadata.Uid` from a procfs lookup that Android Q closed off. Collapsing them back into one call that returns only a\n  package name is what left every connection reporting uid 0, so `UID` rules matched nothing.\n- The desktop delivery path in `core/server.go` must not report failures through `logError`. A log event is published to\n  the log subscriber, batched, and handed back to `send`, so a send failure reported that way feeds itself; use\n  `logDeliveryError`, which writes to stderr and latches until a frame gets through or the next connection is installed.\n  A write that fails without putting a byte on the wire — host backpressure hitting `ipcWriteTimeout`, or a payload above\n  `maxIPCFrameSize` — drops that one frame and keeps the connection: the stream is still framed correctly, and tearing it\n  down here ends the read loop, and with it the Core process. A half-written frame whose write merely timed out is\n  resumed for as long as the stall lasts: Windows Modern Standby suspends the app while the Helper's Core keeps running,\n  so the host can stop draining for hours and still come back, and go-winio reports the expiry as its own `ErrTimeout`\n  rather than `os.ErrDeadlineExceeded`, which is why `send` checks `Timeout()`. The wait has no cap on purpose, and\n  `send` holds `writeMu` throughout, so every other frame — method responses and the single batcher goroutine behind\n  the event queues — waits behind the stalled one. Memory is bounded by the queues; what gives is delivery: the state\n  queue fills and `enqueueState` starts dropping, which is the case the `UpdatingAction` sweep above exists for. Nothing\n  on the Dart side restarts the Core over a stall: `CoreRpcClient` times each pending request out on its own and hands\n  the caller `null` (a `no_response` exception for message methods), and the sweep clears core-scope updating state\n  minutes later. Only a half-written frame that fails outright desynchronizes the stream, and that is the one case\n  `send` closes on.\n- Core method handlers are plain functions that return their answer, registered in the `methodHandlers` table in\n  `core/method.go` through the adapter matching their shape. `dispatchMethodCall` already runs every call on its own\n  goroutine and `handleMethodCall` turns a panic into an `internal_error` answer, so a handler blocks for as long as its\n  work takes and never spawns a goroutine just to answer. Work that outlives the answer goes through `safeGo`, which\n  recovers; a bare `go` puts a panic outside every recovery and kills the process, which on Android is the whole\n  application. The exported Android entry points in `core/lib.go` do not reach `handleMethodCall`, so each one carries\n  its own recovery.\n- A handler fails by returning an `error`: a `*MethodError` travels as it is and anything else under `core_error`. The\n  exception is `withMessage`, for the methods whose result the host reads as a message that is empty on success\n  (`_invokeMessage` in `lib/core/interface.dart`): there a plain error becomes that message and only a `*MethodError`\n  fails the call. Moving a method between the two changes what Dart receives.\n- `dialer.DefaultSocketHook` and `process.DefaultPackageNameResolver` are installed exactly once, by `installHooks` in\n  `core/lib.go`, and never cleared. mihomo checks `DefaultSocketHook` for nil once and dereferences it again when the\n  socket is created (`component/dialer/socket_hook.go`), so clearing it while a dial is in flight calls a nil func\n  value. Stopping the TUN swaps `activeTunHandler` instead.\n- `tunnel.AllProxies()` returns a shared, cached map — never modify it. The cache is invalidated by\n  `invalidateAllProxies` on `tunnel.UpdateProxies` and validated against each provider's `Version()`, so a rebuild\n  costs one read per provider rather than one per proxy. Anything else added to `tunnel/patch.go` that derives from the\n  proxy set needs both signals: the external controller can reload the config through `hub/route/configs.go` without\n  going through FlClash's `applyConfig`, so a hook on the FlClash side alone would miss a profile switch.\n- Core state that mirrors mihomo state goes stale at the next `applyConfig`, which replaces every proxy, provider and\n  rule. Read the tunnel instead of caching a snapshot of it: `lookupExternalProvider` kept one that was rebuilt only\n  when the host asked for the provider list, and the host asks after a successful setup and not after a failed one, so\n  an update ran against a provider the tunnel no longer held — downloading, writing to disk and reporting success\n  against nothing.\n- Selection writes take `selectMu`, not `configMu`. mihomo's `Selector.Set` has no lock of its own, so the writes need\n  mutual exclusion against each other and against `patchSelectGroup` — but not against a whole config apply, which is\n  what `configMu` made a proxy switch wait for, provider downloads included. `patchSelectGroup` takes `selectMu` under\n  `configMu`, fixing the order as `configMu` → `selectMu`.\n- Queueing for the delay-test semaphore and probing the node each get the full timeout in `handleTestDelay`. With one\n  shared deadline, a node that waited behind a saturated semaphore reached `URLTest` with almost nothing left, so a bulk\n  test of a large subscription reported whatever sat at the back of the queue as unreachable.\n- A delay test that the Core does not answer is a fault of the Core or the channel, never a verdict on the proxy:\n  `handleTestDelay` returns within its two timeouts on every path. `asyncTestDelay` therefore returns null instead of a\n  `-1` delay, and `ProxiesAction` leaves the last measurement in place and abandons the rest of the run. Writing a\n  timeout there is what made a reachable node read as unreachable whenever the host deadline beat the Core's.\n- Delay-test progress lives in `pendingDelayTestsProvider`, not as a sentinel value in `DelayDataSource`. A delay of 0\n  used to mean \"testing\", which let a result and the state of a test overwrite each other and left cards spinning\n  forever when the Core restarted. The run owns its keys and releases them in a `finally`, so nothing depends on a\n  reply arriving; core status leaving `connected` cancels every run in flight.\n- Some constants in `lib/common/constant.dart` mirror the Core and move with it. `delayTimedOutValue` is the `-1` that\n  `core/delay.go` reports for a delay test that ran out of time; any other negative value failed outright.\n  `downloadTimeoutDuration` matches mihomo's `resource.DefaultHttpTimeout`, which bounds the Core's provider downloads\n  end to end. `shareLinkSchemes` are the schemes mihomo's `ConvertsV2Ray` reads, less http(s), which is a subscription.\n- Anything on the mihomo side that is reached from both a user-triggered core method and mihomo's own background\n  scheduler needs its in-flight guard on the FlClash side. `updater.UpdateMMDB` and its siblings have none — only the\n  batch `UpdateGeoDatabases` does — and two concurrent runs close the mmap'd database twice, so `handleUpdateGeoData`\n  claims per resource and `updater.GeoUpdateHook` releases.\n- A failed `applyConfig` rolls the tunnel back to the default config, and that rollback is the whole recovery:\n  `handleSetupConfig` returns the error and stops there. Do not add a teardown on top of it — stopping the listeners\n  takes the app offline over a profile the user can still switch away from, and the error already reaches the host,\n  which is what surfaces the failure (`MessageException` on the Flutter side, the config-error toast on Android). Keep\n  an empty `config.yaml` out of the failure path — it is how the app says \"no profile selected\", and `loadConfig`\n  resolves it to the defaults.\n- Package `init` in the Android library runs while the `.so` is being loaded, so a panic there takes the application\n  down before it can report anything. `platform/limit.go` arms an fd-pressure probe and degrades to never blocking when\n  it cannot; keep that shape for anything else `init` sets up that correctness does not depend on.\n- No file in `core/` uses cgo: the only C in the Android library is the glue gobind generates during `gomobile bind`.\n  Android-only files take `//go:build android` and desktop-only ones `!android`, so `GOOS=android CGO_ENABLED=0 go vet`\n  checks the Android side without an NDK. Keep it that way; cgo in `core/` puts the NDK back in front of that vet and of\n  `go build ./...` on every developer machine.\n\n## Lifecycle Rules\n\n- Crash recovery is owned by `BootGuard` (`lib/common/boot_guard.dart`), and the signal it acts on is the persisted\n  `BootRecord`, never a crash reporter. The record is stamped `starting` before `startCore` and `running` once\n  `_initApp` finishes, so only a launch that died before reaching `running` is a failed launch — a crash after hours of\n  runtime, or a process the system reclaimed in the background, is not one. `ApplicationExitInfo` can only veto a\n  failure (user stop, low memory, signal, package change), never create one, and its timestamp is consumed through\n  `handledExitAt` so the same exit cannot be counted twice.\n- `BootGuard` is an Android-only mechanism and gates itself: on every other platform `evaluate`, `markRunning` and\n  `markClosed` return without touching preferences. The desktop has no `ApplicationExitInfo` equivalent to attribute an\n  exit, so a bare sentinel there would read a window closed on the disclaimer dialog, or a session ended by shutdown,\n  as a failed launch. Keep the platform check inside the guard; callers in `bootstrap.dart` and `SystemAction` stay\n  unconditional.\n- Recovery is graded: the first failed launch only skips `initStatus`, and `currentProfileId` is cleared only from\n  `crashRecoveryClearThreshold` consecutive failures on. Do not let a single interrupted launch write to the config.\n- Desktop process ownership belongs to `DesktopCoreLifecycle`; do not start/kill `FlClashCore` from providers, widgets,\n  managers, or ad hoc exit callbacks. Acquire and release it through a `CoreProcessLease`.\n- `CoreController.close()` and platform `close()` implementations are terminal and idempotent. Application shutdown must\n  stay centralized in `SystemAction`/`SystemExitCoordinator`.\n- Android start/stop MethodChannel calls are optimistic UI commands. Keep latest-wins arbitration in native\n  `ServiceState`; do not add a Flutter completion callback that creates a second lifecycle owner. What flows back is\n  the revision-stamped `stopped` report, which Flutter mirrors with `setRunning(false)` and never answers with a\n  decision of its own.\n- Android `RunState` is derived in `ServiceStateMachine.publish`; do not assign it from a transition step, and do not\n  add a second \"is it running\" field beside `ServiceController.runtime`. Every divergence between the tile and the\n  service so far came from a copy that one path forgot to update.\n- Android service callbacks are not automatically user intent. Route explicit Quick Settings, Always-on VPN, and revoke\n  actions through `ServiceState` and keep `ServiceController` as the sole binding/run-time owner.\n- Every `BroadcastReceiver.goAsync()` path must finish its `PendingResult` exactly once. A watchdog may release the\n  broadcast lease, but must not cancel, reverse, or otherwise redefine the service operation.\n- Anything that probes through the outbound route is a `RoutedProbe` judged against `routeTrackerProvider` (Route\n  Consistency in `.agents/architecture.md`); the Core stamps every answer with its counters and hits and checks the\n  hits again on a move, so do not add a change signal, a trigger counter, or a `coreStatusProvider` watch of your\n  own.\n- Presentation smoothing such as `CoreStatusButton`'s connecting hold must remain local display state. It must not delay or\n  overwrite `coreStatusProvider`, and a real failure must bypass/cancel the hold immediately.\n- Safe mode (`SAFE_MODE` define, see the safe mode section of `.agents/architecture.md`) is consulted by each host\n  integration at its owner, never by rewriting the user's settings. `safeModeBuild` is for handles created before the\n  container exists; everything else reads `safeModeProvider` so a test can override it.\n- `Tray.hide()` is idempotent on all three desktop platforms and returns native state to \"`show` was never called\".\n  `AppTray.shutdown()` latches, so no later `update()`/`updateTitle()` can resurrect the icon once shutdown begins.\n  Keep it that way; a resurrected icon outlives `exit(0)` as a Windows ghost icon, because `setPreventClose(true)`\n  means `WM_DESTROY` never runs.\n- The `tray` plugin owns call ordering, idempotency, serialization, and unchanged-payload suppression. Application code\n  declares desired state through one `Tray.show(TraySpec)` call and must not add platform branches to work around\n  ordering. Platform branches in `lib/common/tray.dart` are only for deliberate product differences (macOS speed title\n  and group submenus); query `Tray.instance.capabilities` for ability differences.\n- Every native `show` returns whether the tray accepted the payload, and reports `false` instead of showing a broken\n  icon. `Tray` caches the payload signature only on `true`, so a rejected `show` is retried by the next update rather\n  than suppressed until restart. Windows accepts a payload the shell refused, as at sign-in, and retries the icon\n  natively until it lands, so `Tray` still treats it as visible and `hide` reaches native to delete an add that may\n  have landed. Any test that mocks the `tray` channel must return `true` from `show`.\n- Reading the Wi-Fi SSID is opt-in work, not ambient state. `ConnectivityManager` reads it only while `excludeSSIDs` is\n  non-empty, because that list is its only consumer through `suspendProvider`, and the read costs a blocking platform\n  call plus a location permission on Android and macOS. A second consumer must widen that gate, not drop it.\n- On Android the `wifi_ssid` permission is only `granted` once `ACCESS_BACKGROUND_LOCATION` is held too (Q+). Fine\n  location alone reads the SSID in the foreground but returns `UNKNOWN_SSID` once the app is backgrounded, which is\n  where on-demand spends most of its time. `getSsid` itself only needs fine location, so the split stays inside the\n  plugin.\n- No `wifi_ssid` implementation may answer `getSsid` on the platform thread. Android resolves through a network callback\n  with a timeout, Linux through `g_task_run_in_thread`, and macOS through `ssidQueue` after checking the location\n  authorization, because CoreWLAN reaches `wifid` over XPC and a wedged daemon would freeze the window. Windows is the\n  outstanding exception: `WlanQueryInterface` still runs inline, since replying off-thread there needs a window-message\n  hop that the plugin API does not provide.\n- Android's `system` nameserver resolves only through the DNS `NetworkObserver` reports; mihomo builds it with no\n  fallback, so with no list every lookup through it fails at once. The Core tests delays and updates providers with no\n  service running, so the observer lives for the process from the first `init` or native `quickSetup` and never sends\n  an empty list on a service stop.\n- The delayed DNS re-check `NetworkObserver.onLosing` posts is deliberately left un-deduplicated. The runnable\n  re-reads `networkInfos` and does nothing when `onLost` already dropped the network, and `updateDns` calls into the\n  Core only when the resolved list or the preferred network changed, so a network that reports `onLosing` repeatedly\n  costs two comparisons per event. Holding the pending `Runnable` to `removeCallbacks` it changes no behaviour and adds\n  state that has to stay in sync with the map.\n\n## UI Isolate Work\n\nEverything the UI isolate runs synchronously shares a frame with build, layout and paint: 16 ms at 60 Hz, 8 ms at\n120 Hz. Computation may take as long as it needs anywhere else, so this rule is about where work runs, not how much of\nit there is.\n\n- Work that grows with the user's data (profiles, rules, proxies, groups, dialers, icons, connections) and can take\n  more than about 8 ms on the largest data the app expects runs in an isolate, through `compute` or `Isolate.run`.\n  Thousands of rules or proxies and hundreds of groups are in that range. `makeRealProfileTask` in\n  `lib/common/task.dart` and the backup tasks in `lib/common/backup.dart` are the existing shape.\n- Make the algorithm linear or cached first. Work superlinear in that data, such as a walk of the whole graph per\n  item, a scan of every group per group, or a list copy per inserted element, is a defect on any isolate; moving it\n  only hides the cost until the data outgrows the move.\n- Sending data to an isolate copies it on the sending isolate, so hand the task the smallest input it reads. A value\n  the UI reads synchronously, such as a sync provider watched in `build`, becomes an async provider with a loading\n  state rather than a blocking call.\n- drift runs SQL on its own isolate but maps rows into models on the caller's, so a watched query over a large table\n  costs the UI isolate on every emission, and any write to that table makes it emit again.\n- Bounded work stays where it is: a provider recompute that reuses a cache, a single-item update, a search filter over\n  a list sized for scrolling.\n- An editor document stays in the Rust rope. Open a file for `EditorPage` with `openEditorFile` rather than reading it\n  into a string, and read an edited one through `EditorContent`; a profile can be tens of megabytes.\n- Decide on measurements taken at the expected maximum. A `flutter test` probe that counts provider recomputes, times\n  the provider body and hooks `debugOnRebuildDirtyWidget` shows what reruns and how it scales; its JIT timings are not\n  device numbers, so a case near the line is settled with a profile build. No gate checks this rule.\n\n## Testing Rules\n\nThe `core/` directory is excluded from automated coverage accounting. Do not add coverage instrumentation or coverage\ncollection for code under `core/`. CI still runs `CGO_ENABLED=0 go test .` and `go vet .` to compile/check the Go wrapper,\nplus a `GOOS=android` vet that covers the `android` files the first two exclude; verify cross-language\nprotocol behavior through shared Dart contract tests under `test/core/` and native platform build checks.\n\nA Go test in `core/` that reaches `sendMessage` — directly, or through `handleStartLog` or `updater.GeoUpdateHook` —\nleaves events in the process-wide batcher, which flushes them up to `messageBatchInterval` later into whichever\nconnection `captureFrames` has installed by then. Either keep the event out of the batcher or end the test with\n`settleMessageBatcher`. That batcher runs for the whole test binary and reads `conn` under `connMu`, so install a test\nconnection with `swapConn`; a bare assignment races every event it happens to be delivering, and `go test -race` catches\nit in an unrelated test.\n\nRegister fallback values for freezed params used with `any()` matchers.\n\n`tool/check_coverage.dart` enforces a total floor passed by CI plus per-group floors declared in `_groupFloors`. Raise a\ngroup's floor when new tests lift it; do not lower one to make a run pass.\n\nEvery measured group needs a floor. A group the report measures but `_groupFloors` does not declare fails the run, so\nadding a top-level directory under `lib/` means adding its floor in the same change. Set a new floor at or just below\nthe coverage the directory actually has; the point is to stop a slide, not to backfill tests before the directory can\nland.\n\nPrefer `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))` over `CoreController.test(fake)` in new and\ntouched tests. `CoreController.test` claims the process-wide singleton, which makes a global read and a provider read\nresolve to the same fake, so it cannot fail on a call site that still reaches for the global. A test that does claim\nthe singleton calls `CoreController.resetInstance()` in `tearDown`. It also only reaches the global `coreController`\nwhen nothing has read it yet: that is a top-level `final`, bound to whichever instance existed at the first read, which\nin a shared isolate is another file's.\n\n### Shared Test Isolate\n\n`tool/test.dart` imports many test files into one entrypoint, so process-wide state outlives the file that set it, and\nevery file's `main()` runs before any test of the entrypoint does. Keep `main()` to declarations and\n`TestWidgetsFlutterBinding.ensureInitialized()`: a mock installed there is replaced by the next file's before a test\nsees it. Set up what the tests need in `setUp` or `setUpAll` instead, from the helpers that rebind the singletons:\n\n- `appPath` resolves its directories once per process. Point them at a test's directory with `await useAppPaths(dir)`\n  from `test/helpers/app_paths.dart`, not with a `PathProviderPlatform` fake, which only works while nothing has asked\n  `appPath` yet.\n- `preferences` opens its store once per process. Use `await mockPreferences(values)` from\n  `test/helpers/preferences.dart` rather than `SharedPreferences.setMockInitialValues` alone, which leaves `preferences`\n  on the store it already opened.\n\nBoth cache the resolved value, not the future that produced it. A future completed in one test's fake-async zone runs\nits listeners in that zone, so a later test that awaits it never resumes. Await the helpers outside `testWidgets`\nbodies for the same reason.\n\nA test that only passes in a fresh isolate is leaning on a global it did not set up: `database_test.dart` once caught\nits write failures only because `currentAppLocalizations` threw before anything had loaded them. The script reruns such\na file alone so it cannot fail the run, and lists it; fix it where it reads the global.\n\nConstruct the Android lib handler with `CoreLib.scoped(fakeService)`. The `service` global is gated on `Platform.isAndroid`\nand is therefore null on every test host, so a `CoreLib()` built from it silently takes the null-service fallback on every\npath. `CoreLib.scoped` binds an explicit `Service` instead; reset the singleton with `CoreLib.resetInstance()` in `tearDown`.\n\nThree globals in `lib/common` reach real host state and carry a `@visibleForTesting` seam to stand in front of it:\n`AutoLaunch.launcher`, `listNetworkInterfaces` and `LinkManager.uriLinkStream`. Replace the launcher in particular — every\n`enable`/`disable`/`isEnabled` writes the actual autostart entry (a LaunchAgents plist, a `.desktop` file or a registry\nkey), so a test that skips the seam registers the test binary on the machine that ran it. `updateStatus` returns early\nunder `kDebugMode`, which is always true beneath `flutter test`, so its remaining branches cannot be reached from a test\nat all; `test/common/launch_test.dart` pins the early return instead.\n\n`pumpAndSettle` never returns on a page holding `EditorView` (what `EditorPage` embeds): the code editor blinks its\ncaret forever, so frames keep being scheduled. Pump explicitly instead. `encodeYamlTask` and its neighbours in\n`common/task.dart` hand work to a real isolate through `compute`, which only runs outside the fake-async zone, so a test\nawaiting one needs `tester.runAsync(...)` between the pumps.\n\nThe `@visibleForTesting` `database` setter in `lib/database/database.dart` deliberately does not close the instance it\nreplaces. Tests inject `NativeDatabase.memory()`, which holds no file handle, and `Database.close()` is async while the\nsetter is not, so closing there would either be unawaited or force the seam to become async for no gain. A test that\ndoes open a file-backed database owns closing it.\n\n`system.isAndroid` / `isMacOS` / `isWindows` / `isLinux` read `dart:io` `Platform` and cannot be overridden, unlike\n`debugDefaultTargetPlatformOverride`. A branch behind one of them is only ever exercised on a host that matches it, so CI\n(`ubuntu-latest`) and a macOS working copy measure different coverage for the same test. Assert host-agnostic behavior,\nand leave headroom under a group floor that covers such a branch.\n\nA platform decision that drives layout takes `isDesktop`/`isMacOS` as parameters and reads `system` only at the call site,\nso every platform's outcome is reachable from one host. `getWindowHeaderHeight` and `showsWindowHeader` in\n`lib/common/layout.dart` own the window header rule for both `WindowHeaderContainer` and `overlayTopOffset` — they must\nagree, or the content is offset by a header that is not there. `WindowHeaderBar` takes its height and slots as arguments,\nwhich is what lets `test/manager/window_header_test.dart` measure the Windows caption bar on a macOS host.\n\nA `Stack` under loose constraints sizes to its non-positioned children, and falls back to `constraints.biggest` only when\nit has none — so a bar that fills the window must keep every slot positioned. One non-positioned child is enough to\ncollapse `WindowHeaderBar` to that child's width: the macOS title did exactly that, leaving the app name over the traffic\nlights and the raw window painted black beside it, while Windows, whose slots are all positioned, stayed correct.\n`WindowHeaderLayout` positions the header across the top for the same reason, and the macOS group asserts the bar's\nwidth, not just its height — a height-only assertion cannot see this.\n\nThe same seam carries the two other places a platform decision changed what was built: `AppTray` holds `isMacOS`/\n`isWindows` as state — `AppTray()` fills them from `system`, `AppTray.forPlatform` is the test seam — and `OnDemandView`\ntakes nullable `isAndroid`/`isMacOS` that fall back to the host. Every test names the platform it means, because\n`debugDefaultTargetPlatformOverride` does not move `system` and a suite that leaves it to the host asserts the macOS\nshape on a developer machine and the Linux one on CI. `WindowHeaderContainer` builds the caption buttons on every\nnon-macOS host, so a test mounting it needs `TestApp` for `AppLocalizations` and a `window` channel mock that\nanswers `isMaximized`/`isAlwaysOnTop` with a bool.\n\nAuto-dispose providers need a container-level hold before a test reads them back. `proxyGroupProvider`, `ruleProvider`,\n`itemsProvider` and friends mix in `AutoDisposeNotifierMixin`, so a `container.read` that no widget is currently watching\nrebuilds the provider from its override and silently discards whatever the code under test wrote. Add\n`container.listen(theProvider, (_, _) {})` in the harness, as `test/providers/action_test.dart` does for `scriptsProvider`.\n\nA field that constructs its own `ValueNotifier`, `TextEditingController`, `ScrollController`, `FocusNode`, `TabController`,\n`PageController`, `AnimationController` or `StreamController` must be released in the same file.\n`test/lint/disposable_field_test.dart` enforces this by scanning `lib/`, because no lint covers it: `close_sinks` only sees\nsinks, and nothing in the standard set tracks `ChangeNotifier` disposal. A field that genuinely outlives its owner goes in\nthat test's `_allowed` set with the reason, not left bare. Controllers received as widget parameters belong to the caller\nand are out of scope.\n\nAn `IconButton` whose icon is an icon needs a `tooltip`. It is the button's only accessible name — without it TalkBack and\nVoiceOver announce nothing and the desktop build shows no hover hint. `test/lint/icon_button_tooltip_test.dart` enforces\nit and skips exactly two shapes: an `icon:` holding a `Text`, which is already a visible label, and\n`views/dashboard/widgets/core_status_button.dart`, which takes its label from an enclosing `Tooltip` (a second test fails\nif that wrapper disappears). Reuse an existing string before adding one; a label that depends on state goes on the button\ninside the `ValueListenableBuilder`, not outside it, or the tooltip cannot follow the icon. A row of window buttons hidden\nbehind `system.isMacOS` is unreachable from a macOS test host, so extract it — `WindowHeaderActions` is the pattern.\n\nA tooltip needs an `Overlay` ancestor at build time, not at hover time: `RawTooltip` builds an `OverlayPortal`, so a\nbutton whose `tooltip` has nowhere to go throws \"No Overlay widget found\" and takes its whole subtree down with it.\nEverything `buildManagerStack` wraps around `MaterialApp.builder`'s child sits *above* the app Navigator and therefore\nabove the only Overlay in the tree. A manager that renders a tooltip — `WindowHeaderLayout` and its caption buttons are\nthe case that broke Windows while macOS, which renders a bare title there, stayed clean — hosts its own with\n`Overlay.wrap`, spanning the window so the tooltip is not clipped to the widget that owns it. A test only reproduces this\nby mirroring that topology: build the widget from `MaterialApp.builder`, never from `home:`.\n\nA public top-level declaration that nothing outside its own file references is dead, and no lint catches it:\n`unused_element` covers only private ones, and a barrel `export` keeps a dead file compiling and off every\n\"unused import\" report. `test/lint/dead_file_test.dart` scans `lib/` for files whose declared names — types plus the\n`final appPath = AppPath()` singletons next to them — appear nowhere else, counting generated code as a consumer (a\nriverpod notifier is reached through its generated provider) and barrels as neither. Files publishing only extensions or\ntypedefs are skipped: those are reached through the types they attach to, never by name.\n\n`CommonScaffold` runs its body under the system bar at the foot of the window, as Android's edge-to-edge guidance\nasks, and only the end of the content clears it. A three-button bar is no exception: `ThemeManager` leaves it\nAndroid's translucent scrim (`systemNavigationBarContrastEnforced`). Telling the two apart by the side back-swipe\nzones Flutter reports reserved a gesture handle again on a real phone, so no page asks which bar it has. A scroll view\nends, and anything fixed at the foot of a body stands, `context.bottomInset` above the edge: `BottomInsetScope` (the\nhome dock, a docked search) plus the system bar `MediaQuery` still carries. The home shell takes the system bar out of\n`MediaQuery` for its tabs because the dock's inset already counts it. A bare `BottomInsetScope.of` leaves the system\nbar out, so code outside `lib/ui` never reads it. The scaffold adds its docked search to the scope around its body, so a page with a `searchState` reads\n`context.bottomInset` below the scaffold, inside `body: Builder(builder: (context) => ...)`; read with the context the\nscaffold is built from, it leaves the end of the content under the search field.\n`test/lint/scaffold_bottom_inset_test.dart` enforces both by scanning `lib/`.\n\nEvery reorderable list builds its drag proxy with `commonProxyDecorator`, which tells `DecorationListItem` it is lifted:\nthe row drops its separator and rounds from its corners in the run to a card of its own as it lifts, and back as it\ndrops. A list without it lifts a middle row with square edges. `test/lint/reorder_proxy_decorator_test.dart` enforces\nthis by scanning `lib/`.\n\nA `State.dispose()` override must not await before `super.dispose()`. `StatefulElement.unmount` calls `dispose()` and then\nimmediately asserts that `super.dispose()` already ran, so an `await` defers the call past the assert and every teardown\nthrows \"`…State.dispose failed to call super.dispose.`\" in debug and profile builds. Declare the override as `void\ndispose()` and hand async teardown to `unawaited(...)`; `Future<void> dispose() async` compiles and is the shape that\ninvites the bug.\n\nUse `ProviderContainer` directly for simple Riverpod provider tests, released with `addTearDown(container.dispose)`.\nAssert against the provider's generated public API, not its implementation, re-read source defaults before asserting\nthem, and wait on provider futures or state changes rather than fixed sleeps. The generated Riverpod `update()` method\ntakes a callback:\n\n```dart\nnotifier.update((state) => newValue);\n```\n\nWhen testing freezed models with nested objects, always round-trip through `jsonEncode` and `jsonDecode`. Direct `fromJson(toJson())` fails for nested freezed types because `toJson()` stores child objects directly instead of maps.\n\nFor async widgets, put visual cleanup in `finally` when the action may throw. Focused widget tests should cover success,\nfailure, disposal, and any timer boundary that changes visible state.\n\n## Running the App Locally\n\n- An agent starts FlClash on the host only as a safe mode build (`--dart-define=SAFE_MODE=true`, see\n  `.agents/commands.md`). A normal build takes over the host network, so the user runs it, never the agent.\n- Never end a FlClash process the agent did not start, and never kill by name (`pkill`, `killall`, a pattern on the\n  executable path): the user's own instance matches too. Record the pid of every process you launch, check with\n  `ps -o pid,command -p <pid>` that its executable is your own build output, and end only that pid.\n- Only a macOS or Android debug build has its own data directory; every other build shares the installed app's data\n  directory and single-instance lock, so it cannot start while the user's instance runs. `.agents/commands.md` lists\n  which builds are kept apart.\n\n## Commit Messages\n\nSubjects follow Conventional Commits and are enforced by the `commit-msg` hook in `.pre-commit-config.yaml`, which runs\n`tool/hooks/commit_msg.sh`:\n\n```text\n<type>[(scope)][!]: <description>\n```\n\n- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`.\n- Scope is optional and lower case; use a comma to list several, as in `fix(core,android)`.\n- `!` before the colon marks a breaking change.\n- Descriptions start in lower case, omit the trailing period, and keep the whole subject within 100 characters.\n  Identifiers and acronyms keep their own casing, as in `fix(ui): AppBar text is truncated`.\n- `Merge`/`Revert` subjects and `fixup!`/`squash!` commits are exempt.\n- No `Co-authored-by` trailer crediting a coding agent, whatever that tool's own convention says. The history\n  records who owns the change, not which tool typed it; human co-authors are still fine. The hook rejects the\n  known agent identities.\n\nWrite what the change does, not that something changed: `perf(views): stop redoing per-frame work in build`, not\n`Optimize more details`.\n\nInstall the hooks once with `pre-commit install --hook-type pre-commit --hook-type pre-push --hook-type commit-msg`.\n\n### Fixup Commits\n\nA follow-up to a change that has not shipped in a stable release goes in as `git commit --fixup=<target>`, aimed at the\ncommit that introduced what it amends.\n\n- 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\n  for fixups, so do not steer away from it or flag it.\n- A commit a stable tag contains takes no fixup; land the follow-up as a regular commit. Check with\n  `git tag --contains <target> | grep -v -- '-pre'`, which must print nothing.\n- Asking for a fixup means creating the `fixup!` commit only. Do not run `git rebase --autosquash` or otherwise fold it\n  into its target unless the user asks for the squash.\n\n### Changelog Trailers\n\n`tool/changelog.dart`",".codex/config.toml":"[[hooks.PostToolUse]]\nmatcher = \"apply_patch|Bash\"\n\n[[hooks.PostToolUse.hooks]]\ntype = \"command\"\ncommand = \"bash tool/hooks/comment_density.sh\"\ntimeout = 15\nstatusMessage = \"Checking comment density\"\n",".agents/skills.md":"# Agent Skills\n\nRepo skills live under `.agents/skills/*/SKILL.md`, where Codex discovers them directly. Claude Code discovers them\nthrough the symlinks in `.claude/skills/`, one per skill, pointing back at the `.agents/skills/` directory. Both tools\nmatch a task against the skill `name` and `description`, then load the full instructions only when it matches.\n\n## Available Repo Skills\n\n- `localization`: hardcoded UI text scans, ARB updates, locale generation, and localization verification.\n- `ui-work`: Flutter UI, widgets, Material You styling, icons and glyphs, shapes and corner radii, navigation surfaces,\n  async feedback, and user-facing interactions.\n- `core-platform`: Core lifecycle/process ownership, Android services, Go event delivery, desktop IPC, platform managers,\n  build hooks, system proxy, tray, VPN/TUN, and the Windows and Linux Helper.\n- `pre-commit-quality-review`: lightweight triage before commits, with scoped structural review and user decisions on concrete regressions.\n\n## Authoring Notes\n\n- Add new repeatable workflows as `.agents/skills/<skill-name>/SKILL.md`, and add the matching\n  `.claude/skills/<skill-name>` symlink so Claude Code sees it too; remove both when retiring a skill.\n- Git stores the symlinks as links only where the checkout supports them. A Windows checkout needs\n  `core.symlinks=true` and Developer Mode, otherwise the links become plain files and Claude Code loads no repo\n  skill there; Codex is unaffected because it reads `.agents/skills/` directly.\n- Keep skill descriptions trigger-focused and start them with `Use when...`.\n- Keep long reference material in `.agents/*.md`; skills should link to it instead of duplicating it.\n- Put command permission rules in `.codex/rules/*.rules`, not in this file.\n",".claude/skills/localization":"../../.agents/skills/localization",".claude/skills/pre-commit-quality-review":"../../.agents/skills/pre-commit-quality-review",".claude/skills/core-platform":"../../.agents/skills/core-platform",".claude/skills/ui-work":"# Claude Directive: ui-work\n\nPath: `.claude/skills/ui-work`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/.claude/skills/ui-work)","plugins/code_forge/README.md":"# code_forge Documentation\n\nPath: `plugins/code_forge/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/code_forge/README.md)","plugins/code_forge/analysis_options.yaml":"# Code_forge Skill\n\nPath: `plugins/code_forge/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/code_forge/analysis_options.yaml)","plugins/code_forge/pubspec.yaml":"# Code_forge Skill\n\nPath: `plugins/code_forge/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/code_forge/pubspec.yaml)","plugins/proxy/CHANGELOG.md":"# Proxy Skill\n\nPath: `plugins/proxy/CHANGELOG.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/CHANGELOG.md)","plugins/proxy/README.md":"# proxy Documentation\n\nPath: `plugins/proxy/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/README.md)","plugins/proxy/analysis_options.yaml":"# Proxy Skill\n\nPath: `plugins/proxy/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/analysis_options.yaml)","plugins/proxy/pubspec.yaml":"# Proxy Skill\n\nPath: `plugins/proxy/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/pubspec.yaml)","plugins/rust_api/CHANGELOG.md":"# Rust_api Skill\n\nPath: `plugins/rust_api/CHANGELOG.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/CHANGELOG.md)","plugins/rust_api/CLAUDE.md":"# Rust_api Skill\n\nPath: `plugins/rust_api/CLAUDE.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/CLAUDE.md)","plugins/rust_api/README.md":"# rust_api Documentation\n\nPath: `plugins/rust_api/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/README.md)","plugins/rust_api/analysis_options.yaml":"# Rust_api Skill\n\nPath: `plugins/rust_api/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/analysis_options.yaml)","plugins/rust_api/flutter_rust_bridge.yaml":"# Rust_api Skill\n\nPath: `plugins/rust_api/flutter_rust_bridge.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/flutter_rust_bridge.yaml)","plugins/rust_api/pubspec.yaml":"# Rust_api Skill\n\nPath: `plugins/rust_api/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/pubspec.yaml)","plugins/setup/analysis_options.yaml":"# Setup Skill\n\nPath: `plugins/setup/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/setup/analysis_options.yaml)","plugins/setup/pubspec.yaml":"# Setup Skill\n\nPath: `plugins/setup/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/setup/pubspec.yaml)","plugins/tray/README.md":"# tray Documentation\n\nPath: `plugins/tray/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/tray/README.md)","plugins/tray/analysis_options.yaml":"# Tray Skill\n\nPath: `plugins/tray/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/tray/analysis_options.yaml)","plugins/tray/pubspec.yaml":"# Tray Skill\n\nPath: `plugins/tray/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/tray/pubspec.yaml)","plugins/wifi_ssid/analysis_options.yaml":"# Wifi_ssid Skill\n\nPath: `plugins/wifi_ssid/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/wifi_ssid/analysis_options.yaml)","plugins/wifi_ssid/pubspec.yaml":"# Wifi_ssid Skill\n\nPath: `plugins/wifi_ssid/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/wifi_ssid/pubspec.yaml)","plugins/window/README.md":"# window Documentation\n\nPath: `plugins/window/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/window/README.md)","plugins/window/analysis_options.yaml":"# Window Skill\n\nPath: `plugins/window/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/window/analysis_options.yaml)","plugins/window/pubspec.yaml":"# Window Skill\n\nPath: `plugins/window/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/window/pubspec.yaml)","plugins/setup/setup_hooks/analysis_options.yaml":"# Setup_hooks Skill\n\nPath: `plugins/setup/setup_hooks/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/setup/setup_hooks/analysis_options.yaml)","plugins/setup/setup_hooks/pubspec.yaml":"# Setup_hooks Skill\n\nPath: `plugins/setup/setup_hooks/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/setup/setup_hooks/pubspec.yaml)","plugins/proxy/windows/.vscode/settings.json":"# .vscode Skill\n\nPath: `plugins/proxy/windows/.vscode/settings.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/windows/.vscode/settings.json)","plugins/rust_api/rust/tests/fixtures/profile_config.json":"# Fixtures Skill\n\nPath: `plugins/rust_api/rust/tests/fixtures/profile_config.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/rust/tests/fixtures/profile_config.json)"},"items":[{"name":"rules.md","path":".agents/rules.md","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/.agents/rules.md","title":".agents - Development & Architecture Rules","category":"root-instruction","format":"markdown","content":"# Rules\n\nThese are repository coding and testing conventions. Codex command permission rules belong in `.codex/rules/*.rules`; see `.agents/agent-config.md` before adding those.\n\n## Dart and Flutter Style\n\nThe lint set lives in `lint_options.yaml` at the repo root. The root `analysis_options.yaml` and every local plugin under\n`plugins/*` include it, so application and plugin code are held to the same rules. Add or change a rule there, not in an\nindividual `analysis_options.yaml`; those files carry only their own `analyzer.exclude` entries.\n\n`lint_options.yaml` enforces these non-default rules:\n\n- `prefer_single_quotes: true`: always use single quotes.\n- `require_trailing_commas: true`: use trailing commas in multi-line argument lists.\n- `sort_child_properties_last: true`: `child:` must be the last named parameter.\n- `avoid_print: true`: do not use `print()` calls.\n- `prefer_const_constructors: true` and `prefer_const_declarations: true`.\n- `prefer_final_locals: true` and `prefer_final_in_for_each: true`.\n- `always_declare_return_types: true`.\n- `only_throw_errors: true`: throw an `Exception` or `Error`, never a bare `String`.\n\nFailures whose whole content is a message meant for the user throw\n`MessageException` from `lib/common/exception.dart`. Its `toString()` is the bare\nmessage, which is what `globalState.safeRun` surfaces in the dialog, and the\nthrow stays catchable as an `Exception` and carries a stack trace. Assert on it\nwith `isA<MessageException>().having((e) => e.message, 'message', ...)`, not on a\nraw string.\n\n### Corner Radius\n\nRounded corners are superellipses (continuous corners) everywhere, never circular arcs; when a component offers\nboth, pick the superellipse. `test/lint/superellipse_corners_test.dart` fails on the circular APIs below in `lib/`. Use\nthe superellipse API at each layer:\n\n- Shapes: `RoundedSuperellipseBorder` instead of `RoundedRectangleBorder`.\n- Clips: `ClipRSuperellipse` instead of `ClipRRect`.\n- Container decorations: `ShapeDecoration(shape: RoundedSuperellipseBorder(...))` instead of\n  `BoxDecoration(borderRadius: ...)`; borders move to the shape's `side`, and a `Container` with\n  `clipBehavior` still clips to the shape path.\n- Canvas: `canvas.drawRSuperellipse(RSuperellipse.fromRectAndRadius(...))` instead of `drawRRect`.\n- Ink: `InkWell.customBorder` with an `AppShape` instead of `InkWell.borderRadius`.\n\nPassing `BorderRadius.circular(x)` as the `borderRadius` argument of these APIs is expected — it only\ncarries the corner magnitude; the rendered geometry stays a superellipse.\n\nPills are no exception. `StadiumBorder` joins its round caps to the straight sides with a curvature jump, while\n`AppShape.full`, a superellipse at full radius, blends them, and on a square it renders the exact circle, so it serves\ncircles too. Material 3 defaults every button, icon button, segmented button, and search bar to `StadiumBorder`;\n`ThemeData.withAppShapes` replaces that for the button themes, and a local `FilledButtonTheme` or `IconButtonTheme`\nreplaces the app's, so merge the ambient `XButtonTheme.of(context).style` into it, as `CommonMinFilledButtonTheme`\ndoes. `CircleBorder` and `BoxShape.circle` are fine for something that is always square.\n\nAPIs that accept only a `Radius`, such as `ScrollbarThemeData.radius`, keep circular corners; supply the superellipse\nwith an enclosing clip or shape where it shows. Framework widgets that paint their own circular corners with no shape or\ndecoration to override are left as Material draws them; switches are `CommonSwitch`, drawn by the app. Tooltips take\ntheirs from `withAppShapes`, which sets `tooltipTheme.decoration`.\n\n### Typography\n\nText sizes follow Apple's iOS Dynamic Type scale at its default size, on every platform. `AppTextStyle` in\n`lib/ui/typography.dart` holds its eleven styles, `largeTitle` to `caption2`, with Apple's leading and weights, plus two\nsteps the scale lacks: `display` for a dashboard figure and `pageTitle` for an app bar. Take one with\n`AppTextStyle.body.of(context)`, pass `emphasized: true` for the scale's emphasized weight (bold for the titles,\nsemibold for the rest), and change color or family with `copyWith`; a weight is either regular or emphasized. Text\nsized to the shape it marks, such as a letter scaled to its icon, comes from `AppTextStyle.custom`. The dashboard's\nroles (`DashboardText` in `lib/views/dashboard/style.dart`) are built on the same types. `ThemeData.withAppTypography`\nmaps Material's `TextTheme` slots onto the scale so Material components follow it; app code never reads those slots.\n\nApp bar titles sit at the leading edge in `pageTitle` on a root page and on any desktop page, at the leading edge in\n`headline` on a pushed page in the mobile view, and in the bar's centre in `headline` on a bottom sheet. A title widget,\nsuch as the editor's name field, stays at the leading edge. Past a back button a title keeps the bar's action inset\nclear of the button on either side.\n\n`test/lint/typography_test.dart` fails on `textTheme` anywhere in `lib/` but `typography.dart`, and on `fontSize:` or\n`fontWeight:` outside `lib/ui/`, except in the code editor, whose text follows the editor font-size setting.\n\n### Text Field Suggestions\n\nOn Android, Flutter maps `enableSuggestions: false` to the visible-password input type, not only to \"no suggestions\"\n(`TextInputPlugin.inputTypeFromTextInputType` in the engine). Input methods then treat the field as a password: no\nclipboard panel, no Chinese layouts, and on some systems a secure keyboard. With the app's clipboard permission denied,\nthat leaves no way to paste a link at all. Only the secret fields, `SecretTextFormField` and `FormRowSecretTextField`,\nask for a password keyboard; a link, key, rule or regex field that should not be corrected sets `autocorrect: false`\nalone. `test/lint/text_field_suggestions_test.dart` fails on `enableSuggestions: false` or\n`TextInputType.visiblePassword` anywhere else in `lib/`.\n\n### Length Units\n\nFlutter lengths are logical pixels, the unit Android calls dp. Comments, test names, docs and reports give them in dp,\nas in `48 dp`, never px: a reader takes px for physical pixels and misjudges the size by the device pixel ratio. Write\n\"physical pixel\" in full where the code really works in them, as the hairline snapping in\n`lib/ui/list_row_separator.dart` and the Win32 frame metrics in `plugins/window` do; raster image sizes in\n`tool/` and CSS under `site/` keep px. `test/lint/length_unit_test.dart` fails on a number followed by px in `lib/`\nand `test/`.\n\nCI gates formatting: `dart format --output=none --set-exit-if-changed lib test\ntool plugins setup.dart` runs before `flutter analyze`.\n\nThese directories are excluded from analysis:\n\n- `build/**`\n- `lib/l10n/intl/**`\n- `lib/**/generated/**`\n- `plugins/**`\n- the platform runner directories: `android/`, `ios/`, `web/`, `windows/`, `macos/`, `linux/`\n\n## Comments\n\nComments are part of the code and some of them are load-bearing. What this repository limits is the useless and the\nfrequent. Every comment that restates the code devalues the ones carrying real information, until readers skim past all\nof them — a file with three comments that matter is more readable than one with thirty.\n\nThe two halves are enforced differently, because only one of them can be counted. Whether a comment is useless is a\njudgment, and it lives in the rules below and in review. Whether comments are too frequent is arithmetic, and\n`comment-density` measures it.\n\n### What a Comment Must Earn\n\n- Write one when it carries something the code cannot: a non-obvious constraint, an upstream or platform behavior being\n  worked around, a reason the next reader would otherwise get wrong, a coupling to a value defined somewhere else.\n- Never restate what the code already says. If the comment and the line below it convey the same thing, the comment is\n  noise.\n- Never narrate. Not what this diff changed, not what the code used to be, not the order you did things in. Version\n  history belongs in git and change rationale belongs in the commit message.\n- Never annotate line by line or statement by statement. A block that seems to need a comment per step needs better\n  names or a smaller decomposition instead — reach for structure before prose.\n- Prefer encoding the intent in structure and naming. A named mixin, type, or method that makes the invariant hard to\n  break beats a paragraph asking the next reader not to break it.\n- Delete commented-out code, stale version notes, and comments that only restate the code, whenever you edit the file\n  that contains them. This does not need approval.\n- These are not comments and are exempt from every rule here, including the density count: analyzer and linter\n  directives (`// ignore:`, `// ignore_for_file:`, `// coverage:ignore`), license and copyright headers,\n  code-generation markers, and comments inside vendored upstream code such as `lib/ui/open_container.dart`.\n\n### Density\n\n`tool/hooks/comment_density.sh` fails a file whose added lines are more than 5% standalone comment lines, ignoring\ndiffs under 20 added lines so small edits are never caught. The number is calibrated on this repository's own history:\nhealthy changes sit at or under 3.6%, while the core fix that prompted the gate ran 22.4%.\n\nIt is a ceiling on frequency, not a target to fill. Do not read `core/`'s inherited mihomo density as a quota either —\nit is forked upstream code, not a house style.\n\nThree gates run the same script, and they do not have equal force. The `PostToolUse` hooks in `.claude/settings.json`\nand `.codex/config.toml` run after the tool and hand offending lines back as feedback; neither can undo the completed\nwrite. The `comment-density` pre-commit hook fails the commit, and it is the only hard gate that covers every tool.\nBehavior is pinned by `tool/hooks/comment_density_test.sh`, which CI runs through `tool/hooks/test.sh`.\n\nWhen a change genuinely warrants more, raise the ceiling for that run rather than working around it:\n`COMMENT_DENSITY_MAX=20 git commit`, or `SKIP=comment-density git commit` to step past it entirely.\n\n### Where Knowledge Belongs\n\nA comment is one of three destinations, and often the weakest. Pick by where the constraint would be violated, not by\nhow important it feels.\n\n- **Assertable behavior goes in a test.** A test is the only form that cannot drift, because it fails when the behavior\n  it describes is broken. Prefer it over both a comment and a document whenever the fact can be checked in code.\n- **Repository-wide defaults, ownership, and invariants go in `.agents/*.md` or a `.agents/skills/*/SKILL.md`.** They\n  are violated from many files, so they must reach every future agent at session start. A comment in one file cannot do\n  that.\n- **A fact that is true only at one call site, and is not visible from that call site, is the one thing a comment does\n  better than a test or a document.** Its value is being in the reader's line of sight at the moment of the edit.\n  `lib/common/constant.dart` carries one: the delay-test concurrency cap is bound to `delayTestConcurrency` in\n  `core/delay.go`, and whoever changes that number must see the constraint on the same screen. This is the case a\n  comment is for; the bar is that no test and no `.agents/` entry could hold the fact instead.\n\nBoth failure directions are real. Moving a local constraint into `.agents/` hides it from the person editing the line;\nleaving a repo-wide policy as a comment reaches only the reader of that one file.\n\n## Core API Safety\n\n- `core/Clash.Meta` is a fork of mihomo, and changes to it are budgeted for features, not repairs. Fixing a bug there is\n  low priority even when the bug is real and the fix is small: every patch is one more thing to carry across an upstream\n  rebase. Solve it on the FlClash side of the boundary and note the mihomo behaviour you are working around. Reach into\n  the submodule only for a feature that has nowhere else to live, or when the problem is one the FlClash patches\n  themselves introduced — and say which of the two it is in the commit message.\n- Do not expose direct filesystem deletion APIs through Core or helper IPC; use\n  a scope-specific cleanup API instead.\n- The desktop IPC socket in `plugins/rust_api` admits a Unix peer only when its effective uid is the app's own or root:\n  the socket lives in `/tmp`, and the Core connects as root whenever it runs setuid for TUN or under the Linux Helper.\n  A launch mode that runs the Core as any other user has to widen `admit` in `ipc/access.rs`, and Windows\n  keeps its identity check on the Dart side, which compares the named-pipe peer PID with the Core it launched.\n- Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, the\n  gomobile binding, and desktop IPC. Do not double-encode `arguments`, `result`, or event batches. A response whose\n  `file` is set was spilled to disk; a transport resolves it with `resolveSpilledResponse` before reading `result`.\n- `core/message.go` carries three event queues, and the split is load-bearing: state (loaded, geo-update,\n  route-changed), priority (every other event), and bulk (log, request, DNS). Priority and bulk evict their own oldest\n  entry under backpressure, except DNS, which enters the bulk queue through `enqueueState` so a lookup burst cannot push\n  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\n  `UpdatingAction` sweeps it as stale minutes later. Do not merge the tiers or give state eviction semantics. `enqueueState` drops silently on a full\n  queue and must stay that way: reaching it means the host stopped reading, which `logDeliveryError` already reports,\n  and reporting it from the message layer feeds the same batcher.\n- Every method of the callback interfaces in `core/mobile` (`TunHandler`, `ResultHandler`) returns an `error`. gobind\n  clears a Java exception and hands it to Go only for a method that declares one; otherwise the exception stays pending\n  and the next JNI call on that thread aborts the process, so a throw in `protect`/`resolveUid`/`resolvePackage`/\n  `onResult` would crash unrelated code. `core/mobile` turns the error into the answer the system gives when it will not\n  help: `false`, `-1`, or an empty package name.\n- `TunHandler`'s hooks in `core/lib.go` check `callbacks` under `th.mu` before every call. `clear` drops the callbacks\n  while connections may still be resolving, and a call through the nil interface panics on a mihomo goroutine, outside\n  every recovery.\n- The Android bridge resolves an owner in two steps — `ResolveUid` then `ResolvePackage` — because mihomo fills\n  `metadata.Uid` from a procfs lookup that Android Q closed off. Collapsing them back into one call that returns only a\n  package name is what left every connection reporting uid 0, so `UID` rules matched nothing.\n- The desktop delivery path in `core/server.go` must not report failures through `logError`. A log event is published to\n  the log subscriber, batched, and handed back to `send`, so a send failure reported that way feeds itself; use\n  `logDeliveryError`, which writes to stderr and latches until a frame gets through or the next connection is installed.\n  A write that fails without putting a byte on the wire — host backpressure hitting `ipcWriteTimeout`, or a payload above\n  `maxIPCFrameSize` — drops that one frame and keeps the connection: the stream is still framed correctly, and tearing it\n  down here ends the read loop, and with it the Core process. A half-written frame whose write merely timed out is\n  resumed for as long as the stall lasts: Windows Modern Standby suspends the app while the Helper's Core keeps running,\n  so the host can stop draining for hours and still come back, and go-winio reports the expiry as its own `ErrTimeout`\n  rather than `os.ErrDeadlineExceeded`, which is why `send` checks `Timeout()`. The wait has no cap on purpose, and\n  `send` holds `writeMu` throughout, so every other frame — method responses and the single batcher goroutine behind\n  the event queues — waits behind the stalled one. Memory is bounded by the queues; what gives is delivery: the state\n  queue fills and `enqueueState` starts dropping, which is the case the `UpdatingAction` sweep above exists for. Nothing\n  on the Dart side restarts the Core over a stall: `CoreRpcClient` times each pending request out on its own and hands\n  the caller `null` (a `no_response` exception for message methods), and the sweep clears core-scope updating state\n  minutes later. Only a half-written frame that fails outright desynchronizes the stream, and that is the one case\n  `send` closes on.\n- Core method handlers are plain functions that return their answer, registered in the `methodHandlers` table in\n  `core/method.go` through the adapter matching their shape. `dispatchMethodCall` already runs every call on its own\n  goroutine and `handleMethodCall` turns a panic into an `internal_error` answer, so a handler blocks for as long as its\n  work takes and never spawns a goroutine just to answer. Work that outlives the answer goes through `safeGo`, which\n  recovers; a bare `go` puts a panic outside every recovery and kills the process, which on Android is the whole\n  application. The exported Android entry points in `core/lib.go` do not reach `handleMethodCall`, so each one carries\n  its own recovery.\n- A handler fails by returning an `error`: a `*MethodError` travels as it is and anything else under `core_error`. The\n  exception is `withMessage`, for the methods whose result the host reads as a message that is empty on success\n  (`_invokeMessage` in `lib/core/interface.dart`): there a plain error becomes that message and only a `*MethodError`\n  fails the call. Moving a method between the two changes what Dart receives.\n- `dialer.DefaultSocketHook` and `process.DefaultPackageNameResolver` are installed exactly once, by `installHooks` in\n  `core/lib.go`, and never cleared. mihomo checks `DefaultSocketHook` for nil once and dereferences it again when the\n  socket is created (`component/dialer/socket_hook.go`), so clearing it while a dial is in flight calls a nil func\n  value. Stopping the TUN swaps `activeTunHandler` instead.\n- `tunnel.AllProxies()` returns a shared, cached map — never modify it. The cache is invalidated by\n  `invalidateAllProxies` on `tunnel.UpdateProxies` and validated against each provider's `Version()`, so a rebuild\n  costs one read per provider rather than one per proxy. Anything else added to `tunnel/patch.go` that derives from the\n  proxy set needs both signals: the external controller can reload the config through `hub/route/configs.go` without\n  going through FlClash's `applyConfig`, so a hook on the FlClash side alone would miss a profile switch.\n- Core state that mirrors mihomo state goes stale at the next `applyConfig`, which replaces every proxy, provider and\n  rule. Read the tunnel instead of caching a snapshot of it: `lookupExternalProvider` kept one that was rebuilt only\n  when the host asked for the provider list, and the host asks after a successful setup and not after a failed one, so\n  an update ran against a provider the tunnel no longer held — downloading, writing to disk and reporting success\n  against nothing.\n- Selection writes take `selectMu`, not `configMu`. mihomo's `Selector.Set` has no lock of its own, so the writes need\n  mutual exclusion against each other and against `patchSelectGroup` — but not against a whole config apply, which is\n  what `configMu` made a proxy switch wait for, provider downloads included. `patchSelectGroup` takes `selectMu` under\n  `configMu`, fixing the order as `configMu` → `selectMu`.\n- Queueing for the delay-test semaphore and probing the node each get the full timeout in `handleTestDelay`. With one\n  shared deadline, a node that waited behind a saturated semaphore reached `URLTest` with almost nothing left, so a bulk\n  test of a large subscription reported whatever sat at the back of the queue as unreachable.\n- A delay test that the Core does not answer is a fault of the Core or the channel, never a verdict on the proxy:\n  `handleTestDelay` returns within its two timeouts on every path. `asyncTestDelay` therefore returns null instead of a\n  `-1` delay, and `ProxiesAction` leaves the last measurement in place and abandons the rest of the run. Writing a\n  timeout there is what made a reachable node read as unreachable whenever the host deadline beat the Core's.\n- Delay-test progress lives in `pendingDelayTestsProvider`, not as a sentinel value in `DelayDataSource`. A delay of 0\n  used to mean \"testing\", which let a result and the state of a test overwrite each other and left cards spinning\n  forever when the Core restarted. The run owns its keys and releases them in a `finally`, so nothing depends on a\n  reply arriving; core status leaving `connected` cancels every run in flight.\n- Some constants in `lib/common/constant.dart` mirror the Core and move with it. `delayTimedOutValue` is the `-1` that\n  `core/delay.go` reports for a delay test that ran out of time; any other negative value failed outright.\n  `downloadTimeoutDuration` matches mihomo's `resource.DefaultHttpTimeout`, which bounds the Core's provider downloads\n  end to end. `shareLinkSchemes` are the schemes mihomo's `ConvertsV2Ray` reads, less http(s), which is a subscription.\n- Anything on the mihomo side that is reached from both a user-triggered core method and mihomo's own background\n  scheduler needs its in-flight guard on the FlClash side. `updater.UpdateMMDB` and its siblings have none — only the\n  batch `UpdateGeoDatabases` does — and two concurrent runs close the mmap'd database twice, so `handleUpdateGeoData`\n  claims per resource and `updater.GeoUpdateHook` releases.\n- A failed `applyConfig` rolls the tunnel back to the default config, and that rollback is the whole recovery:\n  `handleSetupConfig` returns the error and stops there. Do not add a teardown on top of it — stopping the listeners\n  takes the app offline over a profile the user can still switch away from, and the error already reaches the host,\n  which is what surfaces the failure (`MessageException` on the Flutter side, the config-error toast on Android). Keep\n  an empty `config.yaml` out of the failure path — it is how the app says \"no profile selected\", and `loadConfig`\n  resolves it to the defaults.\n- Package `init` in the Android library runs while the `.so` is being loaded, so a panic there takes the application\n  down before it can report anything. `platform/limit.go` arms an fd-pressure probe and degrades to never blocking when\n  it cannot; keep that shape for anything else `init` sets up that correctness does not depend on.\n- No file in `core/` uses cgo: the only C in the Android library is the glue gobind generates during `gomobile bind`.\n  Android-only files take `//go:build android` and desktop-only ones `!android`, so `GOOS=android CGO_ENABLED=0 go vet`\n  checks the Android side without an NDK. Keep it that way; cgo in `core/` puts the NDK back in front of that vet and of\n  `go build ./...` on every developer machine.\n\n## Lifecycle Rules\n\n- Crash recovery is owned by `BootGuard` (`lib/common/boot_guard.dart`), and the signal it acts on is the persisted\n  `BootRecord`, never a crash reporter. The record is stamped `starting` before `startCore` and `running` once\n  `_initApp` finishes, so only a launch that died before reaching `running` is a failed launch — a crash after hours of\n  runtime, or a process the system reclaimed in the background, is not one. `ApplicationExitInfo` can only veto a\n  failure (user stop, low memory, signal, package change), never create one, and its timestamp is consumed through\n  `handledExitAt` so the same exit cannot be counted twice.\n- `BootGuard` is an Android-only mechanism and gates itself: on every other platform `evaluate`, `markRunning` and\n  `markClosed` return without touching preferences. The desktop has no `ApplicationExitInfo` equivalent to attribute an\n  exit, so a bare sentinel there would read a window closed on the disclaimer dialog, or a session ended by shutdown,\n  as a failed launch. Keep the platform check inside the guard; callers in `bootstrap.dart` and `SystemAction` stay\n  unconditional.\n- Recovery is graded: the first failed launch only skips `initStatus`, and `currentProfileId` is cleared only from\n  `crashRecoveryClearThreshold` consecutive failures on. Do not let a single interrupted launch write to the config.\n- Desktop process ownership belongs to `DesktopCoreLifecycle`; do not start/kill `FlClashCore` from providers, widgets,\n  managers, or ad hoc exit callbacks. Acquire and release it through a `CoreProcessLease`.\n- `CoreController.close()` and platform `close()` implementations are terminal and idempotent. Application shutdown must\n  stay centralized in `SystemAction`/`SystemExitCoordinator`.\n- Android start/stop MethodChannel calls are optimistic UI commands. Keep latest-wins arbitration in native\n  `ServiceState`; do not add a Flutter completion callback that creates a second lifecycle owner. What flows back is\n  the revision-stamped `stopped` report, which Flutter mirrors with `setRunning(false)` and never answers with a\n  decision of its own.\n- Android `RunState` is derived in `ServiceStateMachine.publish`; do not assign it from a transition step, and do not\n  add a second \"is it running\" field beside `ServiceController.runtime`. Every divergence between the tile and the\n  service so far came from a copy that one path forgot to update.\n- Android service callbacks are not automatically user intent. Route explicit Quick Settings, Always-on VPN, and revoke\n  actions through `ServiceState` and keep `ServiceController` as the sole binding/run-time owner.\n- Every `BroadcastReceiver.goAsync()` path must finish its `PendingResult` exactly once. A watchdog may release the\n  broadcast lease, but must not cancel, reverse, or otherwise redefine the service operation.\n- Anything that probes through the outbound route is a `RoutedProbe` judged against `routeTrackerProvider` (Route\n  Consistency in `.agents/architecture.md`); the Core stamps every answer with its counters and hits and checks the\n  hits again on a move, so do not add a change signal, a trigger counter, or a `coreStatusProvider` watch of your\n  own.\n- Presentation smoothing such as `CoreStatusButton`'s connecting hold must remain local display state. It must not delay or\n  overwrite `coreStatusProvider`, and a real failure must bypass/cancel the hold immediately.\n- Safe mode (`SAFE_MODE` define, see the safe mode section of `.agents/architecture.md`) is consulted by each host\n  integration at its owner, never by rewriting the user's settings. `safeModeBuild` is for handles created before the\n  container exists; everything else reads `safeModeProvider` so a test can override it.\n- `Tray.hide()` is idempotent on all three desktop platforms and returns native state to \"`show` was never called\".\n  `AppTray.shutdown()` latches, so no later `update()`/`updateTitle()` can resurrect the icon once shutdown begins.\n  Keep it that way; a resurrected icon outlives `exit(0)` as a Windows ghost icon, because `setPreventClose(true)`\n  means `WM_DESTROY` never runs.\n- The `tray` plugin owns call ordering, idempotency, serialization, and unchanged-payload suppression. Application code\n  declares desired state through one `Tray.show(TraySpec)` call and must not add platform branches to work around\n  ordering. Platform branches in `lib/common/tray.dart` are only for deliberate product differences (macOS speed title\n  and group submenus); query `Tray.instance.capabilities` for ability differences.\n- Every native `show` returns whether the tray accepted the payload, and reports `false` instead of showing a broken\n  icon. `Tray` caches the payload signature only on `true`, so a rejected `show` is retried by the next update rather\n  than suppressed until restart. Windows accepts a payload the shell refused, as at sign-in, and retries the icon\n  natively until it lands, so `Tray` still treats it as visible and `hide` reaches native to delete an add that may\n  have landed. Any test that mocks the `tray` channel must return `true` from `show`.\n- Reading the Wi-Fi SSID is opt-in work, not ambient state. `ConnectivityManager` reads it only while `excludeSSIDs` is\n  non-empty, because that list is its only consumer through `suspendProvider`, and the read costs a blocking platform\n  call plus a location permission on Android and macOS. A second consumer must widen that gate, not drop it.\n- On Android the `wifi_ssid` permission is only `granted` once `ACCESS_BACKGROUND_LOCATION` is held too (Q+). Fine\n  location alone reads the SSID in the foreground but returns `UNKNOWN_SSID` once the app is backgrounded, which is\n  where on-demand spends most of its time. `getSsid` itself only needs fine location, so the split stays inside the\n  plugin.\n- No `wifi_ssid` implementation may answer `getSsid` on the platform thread. Android resolves through a network callback\n  with a timeout, Linux through `g_task_run_in_thread`, and macOS through `ssidQueue` after checking the location\n  authorization, because CoreWLAN reaches `wifid` over XPC and a wedged daemon would freeze the window. Windows is the\n  outstanding exception: `WlanQueryInterface` still runs inline, since replying off-thread there needs a window-message\n  hop that the plugin API does not provide.\n- Android's `system` nameserver resolves only through the DNS `NetworkObserver` reports; mihomo builds it with no\n  fallback, so with no list every lookup through it fails at once. The Core tests delays and updates providers with no\n  service running, so the observer lives for the process from the first `init` or native `quickSetup` and never sends\n  an empty list on a service stop.\n- The delayed DNS re-check `NetworkObserver.onLosing` posts is deliberately left un-deduplicated. The runnable\n  re-reads `networkInfos` and does nothing when `onLost` already dropped the network, and `updateDns` calls into the\n  Core only when the resolved list or the preferred network changed, so a network that reports `onLosing` repeatedly\n  costs two comparisons per event. Holding the pending `Runnable` to `removeCallbacks` it changes no behaviour and adds\n  state that has to stay in sync with the map.\n\n## UI Isolate Work\n\nEverything the UI isolate runs synchronously shares a frame with build, layout and paint: 16 ms at 60 Hz, 8 ms at\n120 Hz. Computation may take as long as it needs anywhere else, so this rule is about where work runs, not how much of\nit there is.\n\n- Work that grows with the user's data (profiles, rules, proxies, groups, dialers, icons, connections) and can take\n  more than about 8 ms on the largest data the app expects runs in an isolate, through `compute` or `Isolate.run`.\n  Thousands of rules or proxies and hundreds of groups are in that range. `makeRealProfileTask` in\n  `lib/common/task.dart` and the backup tasks in `lib/common/backup.dart` are the existing shape.\n- Make the algorithm linear or cached first. Work superlinear in that data, such as a walk of the whole graph per\n  item, a scan of every group per group, or a list copy per inserted element, is a defect on any isolate; moving it\n  only hides the cost until the data outgrows the move.\n- Sending data to an isolate copies it on the sending isolate, so hand the task the smallest input it reads. A value\n  the UI reads synchronously, such as a sync provider watched in `build`, becomes an async provider with a loading\n  state rather than a blocking call.\n- drift runs SQL on its own isolate but maps rows into models on the caller's, so a watched query over a large table\n  costs the UI isolate on every emission, and any write to that table makes it emit again.\n- Bounded work stays where it is: a provider recompute that reuses a cache, a single-item update, a search filter over\n  a list sized for scrolling.\n- An editor document stays in the Rust rope. Open a file for `EditorPage` with `openEditorFile` rather than reading it\n  into a string, and read an edited one through `EditorContent`; a profile can be tens of megabytes.\n- Decide on measurements taken at the expected maximum. A `flutter test` probe that counts provider recomputes, times\n  the provider body and hooks `debugOnRebuildDirtyWidget` shows what reruns and how it scales; its JIT timings are not\n  device numbers, so a case near the line is settled with a profile build. No gate checks this rule.\n\n## Testing Rules\n\nThe `core/` directory is excluded from automated coverage accounting. Do not add coverage instrumentation or coverage\ncollection for code under `core/`. CI still runs `CGO_ENABLED=0 go test .` and `go vet .` to compile/check the Go wrapper,\nplus a `GOOS=android` vet that covers the `android` files the first two exclude; verify cross-language\nprotocol behavior through shared Dart contract tests under `test/core/` and native platform build checks.\n\nA Go test in `core/` that reaches `sendMessage` — directly, or through `handleStartLog` or `updater.GeoUpdateHook` —\nleaves events in the process-wide batcher, which flushes them up to `messageBatchInterval` later into whichever\nconnection `captureFrames` has installed by then. Either keep the event out of the batcher or end the test with\n`settleMessageBatcher`. That batcher runs for the whole test binary and reads `conn` under `connMu`, so install a test\nconnection with `swapConn`; a bare assignment races every event it happens to be delivering, and `go test -race` catches\nit in an unrelated test.\n\nRegister fallback values for freezed params used with `any()` matchers.\n\n`tool/check_coverage.dart` enforces a total floor passed by CI plus per-group floors declared in `_groupFloors`. Raise a\ngroup's floor when new tests lift it; do not lower one to make a run pass.\n\nEvery measured group needs a floor. A group the report measures but `_groupFloors` does not declare fails the run, so\nadding a top-level directory under `lib/` means adding its floor in the same change. Set a new floor at or just below\nthe coverage the directory actually has; the point is to stop a slide, not to backfill tests before the directory can\nland.\n\nPrefer `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))` over `CoreController.test(fake)` in new and\ntouched tests. `CoreController.test` claims the process-wide singleton, which makes a global read and a provider read\nresolve to the same fake, so it cannot fail on a call site that still reaches for the global. A test that does claim\nthe singleton calls `CoreController.resetInstance()` in `tearDown`. It also only reaches the global `coreController`\nwhen nothing has read it yet: that is a top-level `final`, bound to whichever instance existed at the first read, which\nin a shared isolate is another file's.\n\n### Shared Test Isolate\n\n`tool/test.dart` imports many test files into one entrypoint, so process-wide state outlives the file that set it, and\nevery file's `main()` runs before any test of the entrypoint does. Keep `main()` to declarations and\n`TestWidgetsFlutterBinding.ensureInitialized()`: a mock installed there is replaced by the next file's before a test\nsees it. Set up what the tests need in `setUp` or `setUpAll` instead, from the helpers that rebind the singletons:\n\n- `appPath` resolves its directories once per process. Point them at a test's directory with `await useAppPaths(dir)`\n  from `test/helpers/app_paths.dart`, not with a `PathProviderPlatform` fake, which only works while nothing has asked\n  `appPath` yet.\n- `preferences` opens its store once per process. Use `await mockPreferences(values)` from\n  `test/helpers/preferences.dart` rather than `SharedPreferences.setMockInitialValues` alone, which leaves `preferences`\n  on the store it already opened.\n\nBoth cache the resolved value, not the future that produced it. A future completed in one test's fake-async zone runs\nits listeners in that zone, so a later test that awaits it never resumes. Await the helpers outside `testWidgets`\nbodies for the same reason.\n\nA test that only passes in a fresh isolate is leaning on a global it did not set up: `database_test.dart` once caught\nits write failures only because `currentAppLocalizations` threw before anything had loaded them. The script reruns such\na file alone so it cannot fail the run, and lists it; fix it where it reads the global.\n\nConstruct the Android lib handler with `CoreLib.scoped(fakeService)`. The `service` global is gated on `Platform.isAndroid`\nand is therefore null on every test host, so a `CoreLib()` built from it silently takes the null-service fallback on every\npath. `CoreLib.scoped` binds an explicit `Service` instead; reset the singleton with `CoreLib.resetInstance()` in `tearDown`.\n\nThree globals in `lib/common` reach real host state and carry a `@visibleForTesting` seam to stand in front of it:\n`AutoLaunch.launcher`, `listNetworkInterfaces` and `LinkManager.uriLinkStream`. Replace the launcher in particular — every\n`enable`/`disable`/`isEnabled` writes the actual autostart entry (a LaunchAgents plist, a `.desktop` file or a registry\nkey), so a test that skips the seam registers the test binary on the machine that ran it. `updateStatus` returns early\nunder `kDebugMode`, which is always true beneath `flutter test`, so its remaining branches cannot be reached from a test\nat all; `test/common/launch_test.dart` pins the early return instead.\n\n`pumpAndSettle` never returns on a page holding `EditorView` (what `EditorPage` embeds): the code editor blinks its\ncaret forever, so frames keep being scheduled. Pump explicitly instead. `encodeYamlTask` and its neighbours in\n`common/task.dart` hand work to a real isolate through `compute`, which only runs outside the fake-async zone, so a test\nawaiting one needs `tester.runAsync(...)` between the pumps.\n\nThe `@visibleForTesting` `database` setter in `lib/database/database.dart` deliberately does not close the instance it\nreplaces. Tests inject `NativeDatabase.memory()`, which holds no file handle, and `Database.close()` is async while the\nsetter is not, so closing there would either be unawaited or force the seam to become async for no gain. A test that\ndoes open a file-backed database owns closing it.\n\n`system.isAndroid` / `isMacOS` / `isWindows` / `isLinux` read `dart:io` `Platform` and cannot be overridden, unlike\n`debugDefaultTargetPlatformOverride`. A branch behind one of them is only ever exercised on a host that matches it, so CI\n(`ubuntu-latest`) and a macOS working copy measure different coverage for the same test. Assert host-agnostic behavior,\nand leave headroom under a group floor that covers such a branch.\n\nA platform decision that drives layout takes `isDesktop`/`isMacOS` as parameters and reads `system` only at the call site,\nso every platform's outcome is reachable from one host. `getWindowHeaderHeight` and `showsWindowHeader` in\n`lib/common/layout.dart` own the window header rule for both `WindowHeaderContainer` and `overlayTopOffset` — they must\nagree, or the content is offset by a header that is not there. `WindowHeaderBar` takes its height and slots as arguments,\nwhich is what lets `test/manager/window_header_test.dart` measure the Windows caption bar on a macOS host.\n\nA `Stack` under loose constraints sizes to its non-positioned children, and falls back to `constraints.biggest` only when\nit has none — so a bar that fills the window must keep every slot positioned. One non-positioned child is enough to\ncollapse `WindowHeaderBar` to that child's width: the macOS title did exactly that, leaving the app name over the traffic\nlights and the raw window painted black beside it, while Windows, whose slots are all positioned, stayed correct.\n`WindowHeaderLayout` positions the header across the top for the same reason, and the macOS group asserts the bar's\nwidth, not just its height — a height-only assertion cannot see this.\n\nThe same seam carries the two other places a platform decision changed what was built: `AppTray` holds `isMacOS`/\n`isWindows` as state — `AppTray()` fills them from `system`, `AppTray.forPlatform` is the test seam — and `OnDemandView`\ntakes nullable `isAndroid`/`isMacOS` that fall back to the host. Every test names the platform it means, because\n`debugDefaultTargetPlatformOverride` does not move `system` and a suite that leaves it to the host asserts the macOS\nshape on a developer machine and the Linux one on CI. `WindowHeaderContainer` builds the caption buttons on every\nnon-macOS host, so a test mounting it needs `TestApp` for `AppLocalizations` and a `window` channel mock that\nanswers `isMaximized`/`isAlwaysOnTop` with a bool.\n\nAuto-dispose providers need a container-level hold before a test reads them back. `proxyGroupProvider`, `ruleProvider`,\n`itemsProvider` and friends mix in `AutoDisposeNotifierMixin`, so a `container.read` that no widget is currently watching\nrebuilds the provider from its override and silently discards whatever the code under test wrote. Add\n`container.listen(theProvider, (_, _) {})` in the harness, as `test/providers/action_test.dart` does for `scriptsProvider`.\n\nA field that constructs its own `ValueNotifier`, `TextEditingController`, `ScrollController`, `FocusNode`, `TabController`,\n`PageController`, `AnimationController` or `StreamController` must be released in the same file.\n`test/lint/disposable_field_test.dart` enforces this by scanning `lib/`, because no lint covers it: `close_sinks` only sees\nsinks, and nothing in the standard set tracks `ChangeNotifier` disposal. A field that genuinely outlives its owner goes in\nthat test's `_allowed` set with the reason, not left bare. Controllers received as widget parameters belong to the caller\nand are out of scope.\n\nAn `IconButton` whose icon is an icon needs a `tooltip`. It is the button's only accessible name — without it TalkBack and\nVoiceOver announce nothing and the desktop build shows no hover hint. `test/lint/icon_button_tooltip_test.dart` enforces\nit and skips exactly two shapes: an `icon:` holding a `Text`, which is already a visible label, and\n`views/dashboard/widgets/core_status_button.dart`, which takes its label from an enclosing `Tooltip` (a second test fails\nif that wrapper disappears). Reuse an existing string before adding one; a label that depends on state goes on the button\ninside the `ValueListenableBuilder`, not outside it, or the tooltip cannot follow the icon. A row of window buttons hidden\nbehind `system.isMacOS` is unreachable from a macOS test host, so extract it — `WindowHeaderActions` is the pattern.\n\nA tooltip needs an `Overlay` ancestor at build time, not at hover time: `RawTooltip` builds an `OverlayPortal`, so a\nbutton whose `tooltip` has nowhere to go throws \"No Overlay widget found\" and takes its whole subtree down with it.\nEverything `buildManagerStack` wraps around `MaterialApp.builder`'s child sits *above* the app Navigator and therefore\nabove the only Overlay in the tree. A manager that renders a tooltip — `WindowHeaderLayout` and its caption buttons are\nthe case that broke Windows while macOS, which renders a bare title there, stayed clean — hosts its own with\n`Overlay.wrap`, spanning the window so the tooltip is not clipped to the widget that owns it. A test only reproduces this\nby mirroring that topology: build the widget from `MaterialApp.builder`, never from `home:`.\n\nA public top-level declaration that nothing outside its own file references is dead, and no lint catches it:\n`unused_element` covers only private ones, and a barrel `export` keeps a dead file compiling and off every\n\"unused import\" report. `test/lint/dead_file_test.dart` scans `lib/` for files whose declared names — types plus the\n`final appPath = AppPath()` singletons next to them — appear nowhere else, counting generated code as a consumer (a\nriverpod notifier is reached through its generated provider) and barrels as neither. Files publishing only extensions or\ntypedefs are skipped: those are reached through the types they attach to, never by name.\n\n`CommonScaffold` runs its body under the system bar at the foot of the window, as Android's edge-to-edge guidance\nasks, and only the end of the content clears it. A three-button bar is no exception: `ThemeManager` leaves it\nAndroid's translucent scrim (`systemNavigationBarContrastEnforced`). Telling the two apart by the side back-swipe\nzones Flutter reports reserved a gesture handle again on a real phone, so no page asks which bar it has. A scroll view\nends, and anything fixed at the foot of a body stands, `context.bottomInset` above the edge: `BottomInsetScope` (the\nhome dock, a docked search) plus the system bar `MediaQuery` still carries. The home shell takes the system bar out of\n`MediaQuery` for its tabs because the dock's inset already counts it. A bare `BottomInsetScope.of` leaves the system\nbar out, so code outside `lib/ui` never reads it. The scaffold adds its docked search to the scope around its body, so a page with a `searchState` reads\n`context.bottomInset` below the scaffold, inside `body: Builder(builder: (context) => ...)`; read with the context the\nscaffold is built from, it leaves the end of the content under the search field.\n`test/lint/scaffold_bottom_inset_test.dart` enforces both by scanning `lib/`.\n\nEvery reorderable list builds its drag proxy with `commonProxyDecorator`, which tells `DecorationListItem` it is lifted:\nthe row drops its separator and rounds from its corners in the run to a card of its own as it lifts, and back as it\ndrops. A list without it lifts a middle row with square edges. `test/lint/reorder_proxy_decorator_test.dart` enforces\nthis by scanning `lib/`.\n\nA `State.dispose()` override must not await before `super.dispose()`. `StatefulElement.unmount` calls `dispose()` and then\nimmediately asserts that `super.dispose()` already ran, so an `await` defers the call past the assert and every teardown\nthrows \"`…State.dispose failed to call super.dispose.`\" in debug and profile builds. Declare the override as `void\ndispose()` and hand async teardown to `unawaited(...)`; `Future<void> dispose() async` compiles and is the shape that\ninvites the bug.\n\nUse `ProviderContainer` directly for simple Riverpod provider tests, released with `addTearDown(container.dispose)`.\nAssert against the provider's generated public API, not its implementation, re-read source defaults before asserting\nthem, and wait on provider futures or state changes rather than fixed sleeps. The generated Riverpod `update()` method\ntakes a callback:\n\n```dart\nnotifier.update((state) => newValue);\n```\n\nWhen testing freezed models with nested objects, always round-trip through `jsonEncode` and `jsonDecode`. Direct `fromJson(toJson())` fails for nested freezed types because `toJson()` stores child objects directly instead of maps.\n\nFor async widgets, put visual cleanup in `finally` when the action may throw. Focused widget tests should cover success,\nfailure, disposal, and any timer boundary that changes visible state.\n\n## Running the App Locally\n\n- An agent starts FlClash on the host only as a safe mode build (`--dart-define=SAFE_MODE=true`, see\n  `.agents/commands.md`). A normal build takes over the host network, so the user runs it, never the agent.\n- Never end a FlClash process the agent did not start, and never kill by name (`pkill`, `killall`, a pattern on the\n  executable path): the user's own instance matches too. Record the pid of every process you launch, check with\n  `ps -o pid,command -p <pid>` that its executable is your own build output, and end only that pid.\n- Only a macOS or Android debug build has its own data directory; every other build shares the installed app's data\n  directory and single-instance lock, so it cannot start while the user's instance runs. `.agents/commands.md` lists\n  which builds are kept apart.\n\n## Commit Messages\n\nSubjects follow Conventional Commits and are enforced by the `commit-msg` hook in `.pre-commit-config.yaml`, which runs\n`tool/hooks/commit_msg.sh`:\n\n```text\n<type>[(scope)][!]: <description>\n```\n\n- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`.\n- Scope is optional and lower case; use a comma to list several, as in `fix(core,android)`.\n- `!` before the colon marks a breaking change.\n- Descriptions start in lower case, omit the trailing period, and keep the whole subject within 100 characters.\n  Identifiers and acronyms keep their own casing, as in `fix(ui): AppBar text is truncated`.\n- `Merge`/`Revert` subjects and `fixup!`/`squash!` commits are exempt.\n- No `Co-authored-by` trailer crediting a coding agent, whatever that tool's own convention says. The history\n  records who owns the change, not which tool typed it; human co-authors are still fine. The hook rejects the\n  known agent identities.\n\nWrite what the change does, not that something changed: `perf(views): stop redoing per-frame work in build`, not\n`Optimize more details`.\n\nInstall the hooks once with `pre-commit install --hook-type pre-commit --hook-type pre-push --hook-type commit-msg`.\n\n### Fixup Commits\n\nA follow-up to a change that has not shipped in a stable release goes in as `git commit --fixup=<target>`, aimed at the\ncommit that introduced what it amends.\n\n- 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\n  for fixups, so do not steer away from it or flag it.\n- A commit a stable tag contains takes no fixup; land the follow-up as a regular commit. Check with\n  `git tag --contains <target> | grep -v -- '-pre'`, which must print nothing.\n- Asking for a fixup means creating the `fixup!` commit only. Do not run `git rebase --autosquash` or otherwise fold it\n  into its target unless the user asks for the squash.\n\n### Changelog Trailers\n\n`tool/changelog.dart`","isInternal":false,"tokens":11631,"sizeBytes":50070},{"name":"skills.md","path":".agents/skills.md","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/.agents/skills.md","title":".agents - Workspace Skills & Capabilities Index","category":"root-instruction","format":"markdown","content":"# Agent Skills\n\nRepo skills live under `.agents/skills/*/SKILL.md`, where Codex discovers them directly. Claude Code discovers them\nthrough the symlinks in `.claude/skills/`, one per skill, pointing back at the `.agents/skills/` directory. Both tools\nmatch a task against the skill `name` and `description`, then load the full instructions only when it matches.\n\n## Available Repo Skills\n\n- `localization`: hardcoded UI text scans, ARB updates, locale generation, and localization verification.\n- `ui-work`: Flutter UI, widgets, Material You styling, icons and glyphs, shapes and corner radii, navigation surfaces,\n  async feedback, and user-facing interactions.\n- `core-platform`: Core lifecycle/process ownership, Android services, Go event delivery, desktop IPC, platform managers,\n  build hooks, system proxy, tray, VPN/TUN, and the Windows and Linux Helper.\n- `pre-commit-quality-review`: lightweight triage before commits, with scoped structural review and user decisions on concrete regressions.\n\n## Authoring Notes\n\n- Add new repeatable workflows as `.agents/skills/<skill-name>/SKILL.md`, and add the matching\n  `.claude/skills/<skill-name>` symlink so Claude Code sees it too; remove both when retiring a skill.\n- Git stores the symlinks as links only where the checkout supports them. A Windows checkout needs\n  `core.symlinks=true` and Developer Mode, otherwise the links become plain files and Claude Code loads no repo\n  skill there; Codex is unaffected because it reads `.agents/skills/` directly.\n- Keep skill descriptions trigger-focused and start them with `Use when...`.\n- Keep long reference material in `.agents/*.md`; skills should link to it instead of duplicating it.\n- Put command permission rules in `.codex/rules/*.rules`, not in this file.\n","isInternal":false,"tokens":388,"sizeBytes":1768},{"name":"AGENTS.md","path":"AGENTS.md","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/AGENTS.md","title":"AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"# AGENTS.md\n\nThis file is the entry point for AI coding agents working in this repository. Keep it small: detailed guidance lives under\n`.agents/`, and discoverable repo skills live under `.agents/skills/*/SKILL.md`.\n\n## Start Here\n\nRead these files before making changes:\n\n- [.agents/project.md](.agents/project.md): project overview, versions, and build dependencies.\n- [.agents/commands.md](.agents/commands.md): build, development, code generation, and test commands.\n- [.agents/rules.md](.agents/rules.md): lint, testing, generated-code, and workflow rules.\n\nRead these only when the task touches their area:\n\n- [.agents/architecture.md](.agents/architecture.md): core integration, providers, database, managers, build system, and\n  local plugins.\n- [.agents/setting-changes.md](.agents/setting-changes.md): whether a changed setting is patched live, re-applies the\n  profile, or asks for an Android VPN restart.\n- [.agents/surfaces.md](.agents/surfaces.md): fill levels, selection and status colors, and the few borders that\n  remain, for any change to how a surface is colored.\n- [.agents/agent-config.md](.agents/agent-config.md): how to choose between `AGENTS.md`, `.agents`, skills, Codex config,\n  command rules, and hooks.\n- [.agents/worktrees.md](.agents/worktrees.md): worktree hygiene across Claude Code, Codex, and Gemini.\n- [.agents/flutter-upgrade.md](.agents/flutter-upgrade.md): SDK-pinned workarounds to recheck before changing the\n  Flutter version.\n- [.agents/skills.md](.agents/skills.md): index of repo-scoped skills in `.agents/skills/`.\n\n## Highest Priority Rules\n\n- When the user explicitly requests a scoped, low-risk change, inspect the relevant context and implement it directly.\n  Do not require brainstorming, design documents, implementation plans, multiple-option proposals, or repeated confirmation.\n  Ask only when material ambiguity, destructive impact, additional authority, or scope expansion could change the result.\n- The request sets the scope. A pre-existing bug, cleanup, or refactor the task did not ask for is a follow-up in the\n  summary, not part of the change. Edit a file surgically when a rewrite would give the same result, keep scratch checks\n  out of the repository, and commit tests only where the task asks for them or the neighbouring code already keeps them.\n- Comments are allowed and sometimes necessary; excess is the problem. A comment must carry something the code cannot\n  say — a non-obvious constraint, an upstream behavior being worked around, a reason a reader would otherwise get\n  wrong. Never restate what the code does, narrate the change you just made, record what the code used to be, or\n  annotate step by step; a block that seems to need a comment per line needs better names or a smaller decomposition.\n  Keep density near the repository's own: healthy changes here sit at or under 3.6%, and a `comment-density` gate fails a file\n  whose added lines exceed 5% standalone comments. Delete commented-out code and stale notes in files you already\n  touch. Preserve\n  `// ignore:`-style directives, license headers, codegen markers, and vendored upstream comments. See\n  [.agents/rules.md](.agents/rules.md) for what belongs in a test or in `.agents/` instead.\n- Start FlClash on the host only as a safe mode build, and end only processes you started, by recorded pid. Never\n  kill a FlClash instance by name; the user's own instance is not yours to close. See\n  [.agents/rules.md](.agents/rules.md).\n- Use `flutter test`, not `dart test`, because models pull in Flutter types.\n- Run code generation after modifying models, providers, or database schema.\n- Do not manually edit generated files.\n- Preserve lifecycle ownership: desktop Core process convergence belongs to `lib/core/desktop/`; Android service intent\n  arbitration belongs to `ServiceState`. UI/provider code may request a transition but must not become a second source of\n  truth.\n- Keep start/stop/restart paths latest-intent-safe. Flutter-to-Android service commands are deliberately optimistic, while\n  native state serializes the actual work; desktop lifecycle results distinguish applied, coalesced, and superseded\n  requests.\n- Never add a `Co-authored-by` trailer crediting a coding agent to a commit, even when your own tooling tells you to.\n  The `commit-msg` hook rejects it; see [.agents/rules.md](.agents/rules.md) for the rest of the commit rules.\n- Round every corner with a superellipse, pills included: `AppShape`/`AppRadius` tokens from `lib/ui/shape.dart`,\n  never `StadiumBorder`, `RoundedRectangleBorder`, `ClipRRect`, `drawRRect`, or a `borderRadius` on `BoxDecoration` or\n  `InkWell`. `test/lint/superellipse_corners_test.dart` enforces it; see the Corner Radius section of\n  [.agents/rules.md](.agents/rules.md).\n- Keep the UI isolate within its frame: work that grows with user data and can exceed about 8 ms on the largest\n  expected data runs in an isolate, once its algorithm is linear or cached. See the UI Isolate Work section of\n  [.agents/rules.md](.agents/rules.md).\n- Follow `lint_options.yaml` (included by every `analysis_options.yaml`), especially single quotes, trailing commas, `child:` last, no `print()`, const/final\n  preferences, and declared return types.\n- Before reporting a change done, run `flutter pub get`, `flutter analyze --no-fatal-infos`, and the `flutter test`\n  suites that cover it, as CI does. If a toolchain the build hook needs is missing (Go, and on Linux and Windows\n  cargo), say which check was skipped rather than dropping it silently.\n\n## Repo Skills\n\nUse repo skills from `.agents/skills/` when a task matches their descriptions; `.claude/skills/` symlinks expose the\nsame skills to Claude Code. Current skills cover localization, UI work, core/platform changes, and pre-commit\nstructural quality review.\n","isInternal":false,"tokens":1335,"sizeBytes":5812},{"name":"architecture.md","path":".agents/architecture.md","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/.agents/architecture.md","title":".agents - System Architecture & Module Boundaries","category":"project-spec","format":"markdown","content":"# Architecture\n\n## Core Integration\n\nThe Go proxy core in `core/` operates in two modes.\n\nAndroid lib mode:\n\n- `gomobile bind` builds `core/mobile` into an AAR: a native library per ABI, shipped as `libclash.so`, and the Java\n  classes gobind generates in `com.follow.clash.core.mobile`. The `:core` Gradle module builds it and takes in its\n  classes and libraries (see Android Core Build).\n- The Android `:core` module owns the binding: `Core.kt` is the only caller of the generated `Mobile` class. Flutter\n  crosses MethodChannels through `lib/plugins/service.dart` and Android's `ServicePlugin` rather than calling the Core\n  directly: `${packageName}/service` for lifecycle commands and Core events, `${packageName}/core` for Core method\n  calls.\n- Flutter runs the UI isolate on Android's main thread (3.47 merges the two and ignores the opt-out in release), so\n  main-thread work anywhere in the process, the Core's included, costs frames. Only sending to Dart needs that thread;\n  a `MethodChannel.Result` may be answered from any thread. `${packageName}/core` is handled on a serial background\n  task queue and answered from the Core's goroutine, an event batch is encoded on the Core's thread and only sent on\n  the main one, `ForceGC` (called from `onLowMemory`/`onTrimMemory`) collects on its own goroutine, and\n  `NetworkObserver` refreshes DNS on its own looper.\n- `lib/core/lib.dart` (`CoreLib`) implements the shared Core interface, gates method calls on its connection completer,\n  initializes and synchronizes Android shared state, and closes the native service path exactly once.\n- Because Core is in the application process on Android, application RSS already includes Core memory.\n\nDesktop core mode:\n\n- Go core runs as a separate process with `CGO_ENABLED=0`.\n- `rust_api`'s `ipc` module provides the native local-IPC primitives: a Unix domain socket on macOS/Linux and a named pipe\n  on Windows, served by an `IpcServer` handle. Dart owns the transport state, RPC correlation, process ownership, and\n  lifecycle convergence above those primitives.\n- `lib/core/service.dart` (`CoreService`) is the composition root. It wires the IPC transport, launcher selection,\n  lifecycle controller, RPC client, and crash-event bridge, and holds no lifecycle logic of its own.\n- `lib/core/desktop/transport.dart` binds an `IpcServer` and turns its events into ready, connected, disconnected, and\n  failed transport events plus a frame stream. A connection that ends on an I/O error is a disconnect carrying that\n  error; failed means the server itself is gone and is terminal for that transport. A replaceable binding keeps RPC\n  subscriptions stable when a failed or stale transport must be rebuilt.\n- `lib/core/desktop/rpc_client.dart` owns request IDs and pending completers, waits up to 10 seconds for a connection,\n  applies a three-minute default method timeout, unwraps `CoreMethodResponse`, and fails all pending calls when transport\n  disconnects or closes.\n- `lib/core/desktop/lifecycle.dart` serializes process intents and owns the authoritative desktop state machine.\n- `lib/core/desktop/launcher.dart` abstracts direct child-process and privileged Helper ownership through idempotent process\n  leases. `lib/core/desktop/helper_client.dart` is the typed local HTTP client for the privileged Helper.\n\n`lib/core/controller.dart` (`CoreController`) selects the implementation based on platform. `lib/core/interface.dart` defines the shared `CoreHandlerInterface`.\n\nKey Go core files:\n\n- `core/protocol.go`: method names, event types, and the parameter and result shapes shared with Dart.\n- `core/method.go`: MethodChannel-style method-call dispatch, the handler table, and response envelopes.\n- `core/lifecycle.go`, `config.go`, `proxy.go`, `delay.go`, `provider.go`, `geo.go`, `connection.go`, `log.go`: the\n  handler functions, one file per area. `core/hooks.go` wires mihomo's hooks to them.\n- `core/message.go`: non-blocking state/priority/bulk event queues and bounded message batching.\n- `core/lib.go`: the Android library API: TUN callbacks, the event listener, and the entry points `core/mobile` binds.\n- `core/mobile/`: the package gomobile binds. gobind generates Java for its exported API, so it speaks only `int32`,\n  `bool`, `string`, `[]byte` and its own interfaces; a Go `int` would become a Java `long`.\n- `core/cmd/core/`: the desktop entry point. `core/` itself is the library package `core`, because gomobile cannot bind\n  a `main` package.\n- `core/server.go`: desktop socket/named-pipe client and framed message forwarding.\n\n## DNS, NTP and Sniffer Overrides\n\nA URL or file profile brings its own sections; a custom profile brings none. The app's DNS page\n(`PatchClashConfig.dns` with `dnsOverrideKeys`) is written over the DNS of every URL and file profile, and offers only\n`DnsOverrideKey.normalProfileKeys`: keys that name nothing of a profile's own, because a subscription's groups and rule\nsets change with every update. A custom profile keeps all three sections in `Profile.overrides`, a `ProfileOverrides`\nstored as JSON in the `profiles.overrides` column, with every key available, and NTP and sniffer exist nowhere else.\n`setupState` resolves `SetupState.overrides` by profile type, intersecting the app-wide keys with the normal set, and\n`_makeRealProfileTask` merges whatever it is handed; there is no on/off toggle, a picked key is the override.\n\nEach `*OverrideKey` names one Clash key, with `parent.field` paths for nested maps, and covers every yaml key of the\nmatching mihomo raw struct; `test/models/{dns,ntp,sniffer}_override_test.dart` read `core/Clash.Meta/config/config.go`\nand fail when a core bump adds or drops one. `overrideJson` builds the fragment in model order and skips a key whose\nvalue is empty, so a key left without a value writes nothing: the core refuses an empty `nameserver` outright.\n`mergeDnsOverride` merges one level deep so a partial fallback filter keeps the profile's other filter keys; NTP spreads\nover the profile's flat `ntp` map. For a profile whose `dns.enable` is not true, the task first fills `baselineDns` for\n`baselineDnsOverrideKeys` (`enable`, `enhanced-mode: fake-ip`, `nameserver`), the one thing the core is never without:\nwith DNS off, mihomo answers every TUN-hijacked query with an error. The baseline stays apart from the model defaults,\nwhich carry no preset of the app.\n\nAn added key starts from the spec's `defaults` through its `OverrideField.reset`, and those defaults are mihomo's: a key\nstarts at the value mihomo parses without it, presets included, such as the nameservers, fake-IP range and filter, and\nNTP server of `DefaultRawConfig`. A key mihomo leaves unset, such as `listen` or a sniff protocol's ports, starts empty\nand writes nothing until the user fills it in, and so does the fallback filter's GeoIP code, whose value is left for the\nuser to choose. mihomo sniffs a protocol listed without ports on that protocol's standard port, which the page shows as\nthe default. The tests above pin the defaults against `DefaultRawConfig`. A spec that mixes in `OverrideQuickEdit` gets\nquick edit, which opens `overrideYaml` in an `EditorPage` and rebuilds the key set from the document through\n`applyOverrideYaml`, rejecting keys outside the section and values the model cannot hold.\n\nmihomo upper-cases sniff protocol names and ignores the deprecated `sniffing` list once `sniff` holds anything, so\n`mergeSnifferOverride` drops the profile's entry for an overridden protocol in any case and first carries `sniffing`\ninto `sniff`. A `rule-set:` key of a custom profile's DNS policy counts as a use of that rule provider, so\n`_resolveInjectedProviders` injects it like one a rule names. Safe mode forces `write-to-system` off on whatever `ntp`\nsection reaches the core, because setting the system clock is a host change; `loadConfig` in the Go wrapper also drops\nit on Android, where the seccomp policy kills the process for `settimeofday`.\n\nTUN is the exception to covering the raw struct. The network settings own only `Tun.coreJson` (`enable`, `device`,\n`stack`, and `auto-route` always on), spread last over the profile's section and patched again on every\n`updateConfig`, so `TunOverrideKey` offers only keys outside it that work in FlClash's TUN. Its keys exist app-wide\n(`PatchClashConfig.tunOverride` with `tunOverrideKeys`, listed in the network page's TUN section as `OverrideEntry`\nrows and added from its last row, as a custom group's options are) for every profile, and per custom profile, whose own keys `ProfileOverrides.overAppTun` lays over the app-wide ones. MTU,\ncongestion control, strict route and the route lists are such keys on desktop, so a profile's own values apply unless\npicked; Android's TUN rows keep them in `Tun`, which its VPN service starts from, and a config saved before the move\ncarries values changed from their defaults into picked keys. `bypassPrivateRoute` stays a network setting: the task\nappends the private ranges to whatever `route-exclude-address` results. `dns-hijack` is a key too: the task writes\n`any:53` beneath the overrides, so a profile's own value never applies, a picked key replaces it, and the key starts at\nthat value. A key sing-tun reads on some platforms only is listed only there\n(`_isRead` in `lib/views/config/tun.dart`): strict route on Linux and Windows, GSO and the interface, user ID and port\nfilters on Linux. `udp-timeout` reaches only the gvisor and system stacks and congestion control only mips, which their\ndescriptions say. Route address sets are left out: sing-tun applies them only through `auto-redirect`.\n`test/models/tun_override_test.dart` fails if a key comes to overlap `Tun.coreJson`. Android starts its VPN from\n`core/tun` without the section, so TUN is listed on desktop only. Being a part of the section, the page has no quick\nedit; see Quick Edit.\n\nA custom profile's overview keeps rows for rules, providers and DNS, which every custom profile runs on, and puts the\ndialer proxies, NTP, sniffer and TUN behind a single \"more\" row, which opens a page listing them with their states.\nBoth lists are the same grouped rows, glyph, title and state, ending in a chevron. Another rarely changed section joins\nthe \"more\" list rather than taking a row of its own.\n\nEvery page is an `OverrideView` over an `OverrideSpec` from `lib/views/config/override.dart`. The spec supplies the\nsection's keys, labels and fields; its `OverrideTarget` is where the model and keys live, `PatchOverrideTarget` for the\napp-wide DNS and `ProfileOverrideTarget` for a profile. Another overridable section is a spec, not a copy of a page.\n\nA config from a release that still had the `overrideDns` toggle keeps, through `Config.fromJson`, the normal keys of\nan on toggle and none of an off one. That release had no custom profiles, so its app-wide NTP override is dropped.\n\n## Listener Exposure\n\n`_makeRealProfileTask` in `lib/common/task.dart` writes `mixed-port`, `allow-lan`, and `external-controller` into every\ngenerated profile. With `allow-lan` off, mihomo's `genAddr` binds each listener to `127.0.0.1`; the external controller is\n`ExternalControllerStatus.close` by default and never binds anywhere but loopback. The task also clears\n`external-controller-tls`, `-unix` and `-pipe` in every mode: the unix and pipe controllers answer without a secret, and\nthe unix one unlinks whatever file sits at its path before opening it to everyone, so a profile may not choose them.\nOutside safe mode, profiles retain `listeners`, `tunnels`, `ss-config`, `vmess-config`, `tuic-server`, `iptables`,\n`dns.listen` and `ntp.write-to-system`. Extra inbounds can bypass the app's LAN and authentication settings, and a TUN\nlistener can change routing independently of the app's TUN toggle. These network and clock effects are an accepted\nboundary; they are not treated as arbitrary filesystem modification. The task disables `iptables` when its\n`inbound-interface` contains a space, because mihomo interpolates it into commands and splits their arguments on spaces.\nSafe mode clears these extra inbounds and iptables, closes DNS listening and disables NTP system time writes even after\nlocal overrides. The editor omits disabled alternate controllers and keeps listener completion.\n\nAn open external controller always has a secret, because mihomo serves a controller with an empty one to every local\nuser and, through CORS, to any page the user visits. `PatchClashConfig.secret` is that secret and it belongs to FlClash,\nas the address does: the task writes it over whatever the profile carries, and `UpdateParams.secret` hands the same value\nto a running Core, which restarts the controller when it changes. `ensureControllerSecret` keeps the pair consistent\n(an open controller gets a generated secret, a closed one drops it, so every opening rotates it) and runs where a\nconfig enters the app: the settings toggle, startup, and `writeConfig` for a restored backup. `_effectivePatchConfig`\nreports a controller without a secret as closed, and `_makeRealProfileTask` independently closes it without a secret\nor in safe mode, so a writer that skips `ensureControllerSecret` opens nothing. The settings page shows the secret in\nan editable row, which refuses an empty value.\n\nThe loopback listener takes local connections without authentication by default: mandatory credentials would lock out\nevery external local consumer a proxy client exists for. The binding only guarantees traffic originates on the device;\nit does not isolate programs already running there, so on Android any app holding `INTERNET` can use the proxy and learn\nthe outbound IP (#1934). The opt-in answer is the local authentication setting (`NetworkProps.authentication`), which spans\nfour paths that must stay in sync: `_makeRealProfileTask` writes the credentials into `authentication` and force-clears\n`skip-auth-prefixes` so a profile cannot silently exempt loopback; `UpdateParams.authentication` applies the same list\nto a running core; `FlClashHttpOverrides` sends the credentials with the app's own proxied requests; and `sharedState`\nwithholds the Android VPN system proxy declaration because `ProxyInfo` cannot carry credentials (TUN still captures that\ntraffic). Desktop system proxy is deliberately not gated — dropping it would leak traffic direct, so the setting's\ndescription tells users to supply credentials manually instead.\n\n### Local Trust Boundary\n\nThe Windows Helper answers only a connection whose client process runs under the owner SID it was installed for;\nthe Linux Helper only the owner uid. A setuid Core is executable by `root:admin` (macOS) or the installing user's primary\ngroup, so its boundary also includes every member of that group, not just the installing account. Profiles cannot choose\na controller address, a secret or alternate controllers. Another account outside those permissions, a subscription,\nor a remote party reaching the privileged Core's control or filesystem APIs is a vulnerability.\n\nA process running as the same account is not stopped. It can ask the Helper to start the Core\nagainst a socket or pipe it owns, or run a setuid Core against a port it listens on, and so become the host that drives\na privileged Core: `initClash` chooses the home directory every path check anchors on, and `getConfig`,\n`getProfileBase`, `getProfileOutbounds` and a `setupConfig` or `buildProfile` base read any path.\nThe Core connects to whatever address it is given and cannot tell that host from FlClash. An account check cannot\ndistinguish it from a same-account process that can already read FlClash's memory and files. This remains a user-to-root\nor user-to-SYSTEM privilege escalation, not a complete security fix. An open external\ncontroller is inside the same boundary: its secret is stored under the account, and `/upgrade` stays mounted (it fetches\nfixed MetaCubeX release URLs, not caller-chosen code). mihomo's `IsSafePath` compares paths lexically, so a symlink inside\nthe home directory escapes it; that fix belongs upstream, not in the submodule.\n\nClosing the same-account case requires limiting what a hijacked Core can do. Keeping the main Core unprivileged and\nmoving host changes into narrow platform helpers requires architectural work, but need not introduce repeated prompts\nor remove ordinary proxy functionality. Features that require additional privileges, such as identifying other users'\nprocesses, need platform-specific handling. Descriptor-based filesystem confinement can preserve ordinary managed\npaths; intentional external symlink targets need explicit allowed roots. These changes have not been implemented.\nAccount authentication, a private socket directory or a symlink check at configuration time does not remove the\nprivileged filesystem operations available to the host. Changes must not widen access to those operations.\n\n### Interface Name Modes\n\nThe same task also writes `interface-name` per `PatchClashConfig.interfaceNameMode`: `clear` (default) forces it empty so a\nsubscription's value cannot survive into the generated profile, `follow` leaves whatever the profile YAML already has, and\n`custom` writes the user's `interfaceName`. mihomo ignores `interface-name` on Android whenever `dialer.DefaultSocketHook`\nis installed (`core/lib.go` `installHooks`, vendored `dialer.go`), so `NetworkListView` in `lib/views/config/network.dart`\nshows the mode picker and text field only on desktop.\n\n### Network Settings\n\n`Tun` holds what Android's VPN service starts from: the switch, device and stack, `mtu`, the mips stack's\n`congestion-controller` and the route lists. The desktop TUN takes only `Tun.coreJson` from it, written over the\nprofile's `tun` section and patched on every update through `tunSchema`; MTU, congestion control, strict route and the\nroute lists reach it as TUN override keys (see DNS, NTP and Sniffer Overrides). `test/models/tun_test.dart` keeps every\n`Tun` key within mihomo's `RawTun` and `coreJson` equal to the keys of `tunSchema`.\n\n`NetworkProps.bypassPrivateRoute` adds `privateRouteAddress` to the excluded routes, through `TunExt.excludedRoutes` on\nAndroid and `_makeRealProfileTask` on desktop. VpnService can only add routes before Android 13, so\n`Tun.vpnRouteAddress` cuts the exclusions out of the routes with `subtractCidrs` and lists every family it routes;\n`VpnService.addRoutes` routes everything only for an empty list. A config saved with the old `routeMode` keeps its\nchoice: `bypassPrivate`, which ignored the route list, loads with the flag on and the list cleared, and `config` with\nthe flag off. No stack in the vendored sing-tun reads `endpoint-independent-nat`, so it is not offered. Android builds\nits TUN from the VpnService descriptor in `core/tun/tun.go`, so only MTU and the congestion controller reach it,\nthrough `VpnOptions` and the `options` argument of `startTun`; the VpnService builder takes the same MTU.\n\nThe TUN fields start at mihomo's own defaults, and a saved zero MTU or empty congestion controller, which mihomo reads\nas those defaults, decodes to them. The outbound rows are plain settings written over every profile, and changing one\nre-applies the profile; keep-alive is forced off on Android, and only Linux honours `routing-mark`.\n\n## Lifecycle Ownership And Convergence\n\n### Shared Flutter Layer\n\n`CoreController.start()`, `restart()`, `stop()`, and `close()` are the only shared lifecycle facade. `close()` is terminal;\ncallers must not try to reuse a closed platform implementation.\n\n`CoreAction` in `lib/providers/actions/core.dart` owns the user-facing Core status and setup sequence:\n\n- `startCore()` publishes `connecting`, starts the platform Core, publishes `connected`, then initializes Core state. A\n  startup error publishes `disconnected` and displays the error.\n- `restartCore()` coalesces overlapping callers behind one worker. `_requestedRestartRevision` records newer requests.\n  After the lifecycle restart, the worker reapplies running state or the profile, rereading `isStartProvider` on every\n  pass, until it has consumed the latest revision, so the newest start intent wins.\n- The provider is an orchestration and presentation layer, not a process owner. Platform lifecycle code remains responsible\n  for determining whether a Core process/service is actually running.\n\nApplication exit is centralized in `SystemAction` and `SystemExitCoordinator`:\n\n1. Optionally save config and clean up DNS, system proxy, and tray resources in parallel.\n2. Close the desktop window.\n3. Call terminal `CoreController.close()`.\n4. Exit the application exactly once.\n\nThe coordinator is idempotent, continues later cleanup steps after an earlier error, preserves the first error for the\ncaller, and uses a three-second watchdog as an emergency application-exit path. `Application.dispose()` and\n`CoreManager.onCrash()` do not independently destroy Core; this avoids competing shutdown owners.\n\n### Applying Setting Changes\n\nA changed setting is patched live through `UpdateParams`, re-applied with the profile, or, for the Android VPN, left\nto a restart the user confirms. [setting-changes.md](setting-changes.md) says which path each setting takes and how to\nchoose one for a new setting.\n\n### Desktop Lifecycle\n\n`DesktopCoreLifecycle` is a latest-desired-intent reconciler, not a queue that blindly executes every request:\n\n- Public intents receive monotonically increasing revisions and target running, restarted, stopped, or closed.\n- Observable phases are `idle`, `starting`, `running`, `stopping`, `failed`, and `closed`.\n- A completed command reports `applied`, `coalesced`, or `superseded`, allowing callers and tests to distinguish a command\n  that won from one satisfied or replaced by a newer compatible intent.\n- Startup opens or replaces the IPC transport, resolves a launcher, generates a 128-bit lowercase hexadecimal session ID,\n  launches Core, and waits for the matching connection. Windows additionally verifies that the named-pipe peer PID equals\n  the process PID returned by the Helper lease.\n- Each running session retains its process owner, lease, PID, session ID, and transport connection generation. Stop waits\n  for both process-exit confirmation and the matching disconnect generation; a missing disconnect replaces the transport\n  before later starts.\n- An unconfirmed process exit is retained as an unconfirmed lease. New start/restart intents fail until ownership can be\n  cleaned up, preventing two Core instances from being treated as the active session. Terminal close may continue on a\n  best-effort basis because the application is exiting.\n- An unexpected disconnect or transport failure while running is converted to `DesktopCoreFailure`, the owned process is\n  cleaned up, and `CoreService` emits a Core crash event for the normal UI recovery path.\n\nDirect launch is used on macOS, inside an AppImage, and as the Windows/Linux fallback when the privileged Helper is not\nready. When the Helper is ready, it owns the Core child and Dart owns it through a session-scoped lease.\n\n### Android Service Lifecycle\n\nAndroid deliberately keeps Flutter requests optimistic and the native layer authoritative:\n\n- `ServicePlugin.start()` and `stop()` acknowledge immediately after submitting intent. They do not wait for service\n  creation, VPN permission, binding, TUN establishment, or teardown.\n- `ServiceState` owns the latest `RunRequest` and the shared configuration. Identity checks discard obsolete work.\n  `startPreparationLock` serializes permission/setup preparation and `transitionLock` serializes actual service\n  transitions.\n- `RunState` is derived, never assigned by a step: `ServiceStateMachine.publish` computes it from the transition in\n  flight, the latest intent, and `ServiceController.runtime`. A service that is up under a stop intent reads `STOPPING`, a\n  start still waiting on a permission reads `STOPPED`, and `STARTED` needs both a running intent and a runtime. The tile\n  and `getRunTime` both read this one value, so they cannot disagree with the service.\n- `ServiceController` owns exactly one `ManagedServiceBinding`, selects `VpnService` or `ProxyService` from `VpnOptions`,\n  binds with a five-second connection timeout, and invokes `ManagedService.start()`/`stop()` off the main thread. The\n  binding and `runtime` (which service runs, and since when) are set and cleared together, by a stop, a failed start, or\n  a lost connection and by nothing else. Flutter's `shutdown` only clears the Core event listener: Android keeps an\n  established `VpnService` alive through its own connection, so a binding dropped on exit left a tunnel that was\n  reported as running but that neither a stop nor a revoke could reach.\n- The Core listens exactly while `runtime` is set: a successful start ends in `Core.startListener()` and every path\n  that clears `runtime` calls `Core.stopListener()`. Both return at once when the Core is already there, which is the\n  usual case, since `startTun`/`stopTun` and Flutter's own `startListener`/`stopListener` got there first. They matter\n  when nothing else does: a proxy-only run stopped from the tile used to keep the mixed port open with nothing\n  reported as running. A start closes after itself the same way: Flutter's `startListener` and the native `quickSetup`\n  both open the ports before any service exists, so a start that ends without a `runtime`, refused or cancelled, stops\n  the empty service once more, without passing through `STOPPING`. Flutter may be gone by then and never see the\n  `stopped` report.\n- A start that does not bring the service up gives its intent back and takes down whatever it left running, unless a\n  newer request has claimed the service. A failed start therefore always ends in `STOPPED`, whichever step refused it.\n  The one start that mints no request is a repeat on a settled run of the same kind, which Flutter sends every time it\n  reattaches: a request refused at a permission prompt there would stop a service that was running fine.\n- Generic service creation/destruction is lifecycle evidence, not user intent. New commands must flow through\n  `ServiceState.requestStart()`/`requestStop()` or the explicit system-action handlers instead of inferring intent from a\n  callback.\n\nQuick Settings, notification, revoke, and Always-on VPN paths converge on the same owner:\n\n- With a Flutter engine attached, `ServiceState.handleStartAction()` forwards through `TilePlugin` to `TileManager`,\n  because only Flutter can apply the current profile. Without Flutter, native code claims the intent, restores\n  `SharedState` from preferences, runs `quickSetup`, checks VPN permission, and runs the start; claiming first is what\n  makes a second action during the setup toggle that start off instead of racing a second setup against it.\n- An engine that attaches to a run the service already has, started that way or outliving the last engine, adopts it:\n  `SetupAction.initStatus` restores the md5 of the config.yaml the Core last accepted and pushes the profile only when it\n  now builds differently. Pushing the same config again rebuilds every proxy group, and mihomo keeps a url-test group's\n  unmeasured pick for ten seconds, so the dashboard's exit and service checks ran a second time once it moved.\n- A stop never needs Flutter. `handleStopAction()` stops the service natively whether or not an engine is attached, so\n  the tile, the notification action and a revoke work even when Flutter's own idea of the state is wrong.\n- Whenever the run ends without Flutter having asked for it (a start that failed, a native stop, a revoke, a lost\n  service), `ServicePlugin` sends `stopped` with the revision of the last `start`/`stop` command it received.\n  `Service` in `lib/plugins/service.dart` drops a report older than its newest command, or one that arrives while its\n  last command was a stop (on-demand suspension stops the service while `isStartProvider` stays true), and\n  `AndroidManager` answers the rest with `setRunning(false)`. The report is read on the platform thread, where the\n  commands arrive, so the revision and the intent it carries describe the same instant.\n- Android may create an Always-on `VpnService` through `onStartCommand()` without FlClash's bound-service path. The service\n  sends the explicit, permission-protected `VPN_START_REQUESTED` broadcast to `ServiceBroadcastReceiver`, which routes it to\n  `ServiceState.handleStartAction()` so Core/configuration and the normal binding are restored before TUN is treated as\n  ready.\n- `VpnService.onRevoke()` stops TUN/modules first, then sends `VPN_REVOKED`; the receiver only requests a stop while\n  `ServiceController.runtime` is a VPN run.\n- `ServiceBroadcastReceiver` uses `goAsync()` and an atomic one-shot completion. Normal completion or a nine-second\n  watchdog calls `PendingResult.finish()` exactly once; the watchdog releases Android's broadcast lease and does not\n  cancel or redefine the underlying lifecycle intent.\n\n## Core Protocol And Event Delivery\n\nThe shared protocol uses `CoreMethodCall(id, method, arguments)` and `CoreMethodResponse(id, result, error)` in both\ndirections. The envelope is the only JSON serialization layer: keep arguments, results, and event data as structured JSON\nvalues rather than embedding pre-encoded JSON strings. Plain domain strings, such as country codes or provider contents,\nremain strings.\n\nA response over 1 MiB goes to a file under `<home>/responses/`, and the envelope carries only its `file`\n(`core/spill.go`). Sent inline, Android copies it into the Java heap three times (the gomobile `byte[]`, the codec and a\ndirect buffer), and desktop IPC drops a frame above `maxIPCFrameSize`. `lib/core/spill.dart` reads a spilled file only\nfrom that directory, decodes it in an isolate and deletes it; `initClash` clears what an earlier run left.\n\nA profile file's proxies, groups and rules never cross the bridge. `getProfileBase` returns the config without\n`proxies`, `proxy-groups`, `sub-rules` and `rules`, with their counts, the profile's MATCH rules and a sha256 digest.\n`SetupAction.getProfile` patches it as before and sends the result as `SetupParams.base.overlay`. `core/profile_base.go`\nputs the profile's bulk sections into the overlay, appends the profile's rules after the overlay's, writes `config.yaml`\nfor the Android service and the controller's reload, and parses it. `config.yaml` is encoded a slice at a time, because on a\nprofile of 100k proxies and 2M rules one `Encode` call doubles the peak heap. The preview reads the YAML `buildProfile`\nmerges into the spill directory, and the extend page only the names\nand types `getProfileOutbounds` lists; a script extension still needs the whole config from `getConfig`.\n\nGo event delivery is intentionally non-blocking:\n\n- Loaded-provider, geo-update, and route-changed events use a state queue that never evicts; the rules for it are under\n  Core API Safety in `.agents/rules.md`. Every other event except the bulk kinds, delay included, uses a 256-entry\n  priority queue. Desktop process crashes are generated locally by `CoreService` from lifecycle failures rather than\n  sent through the Go queue.\n- High-volume log, request/connection, and DNS query events use a separate 256-entry bulk queue, so bulk floods cannot\n  evict state.\n- A full priority or bulk queue evicts only its own oldest event and retries the newest; DNS events never evict. Core\n  work never blocks on event delivery.\n- The batcher flushes at 32 messages or every 16 milliseconds. Priority events are preferred, but one bulk opportunity is\n  guaranteed after eight priority messages to prevent starvation.\n- `sendMessage` drops an event before it is queued when `hasEventListener` is false. That only happens on Android, after\n  the Flutter engine detaches while the service keeps the Core running; request and DNS events would otherwise be\n  encoded per connection and per query for nobody.\n\nDNS query events come from the Clash.Meta fork's `dns/patch.go` and are raised where mihomo writes its `[DNS]` debug\nlog, so the DNS page lists the same resolutions: one event per cache hit, and one per upstream exchange however many\ncallers singleflight merged into it, carrying the upstream whose answer was used. Fake-IP and hosts answers never reach\na resolver and are not recorded. Queries from apps arrive with the `DNSContext` that `Service.ServeMsg` created and\nreport `app`; other initiators come from `resolver.WithInitiator` at the call site (rule matching, DIRECT, proxy\nserver dialing), and unlabeled queries report `other`.\n\nDialer-loop events come from the fork's `component/proxydialer/patch.go`. mihomo follows a `dialer-proxy` that leads\nback to its own proxy until the stack overflows, so a dial that asks the same `dialer-proxy` for an address it is\nalready dialing through it fails with `ErrDialerLoop`. `core/hooks.go` reports each looping name at most once every\nthree seconds as a priority event, which the app shows as an error notice whether or not logs are captured. The custom\nprofile pages flag only the loops FlClash can follow on its own, through a group's own list or an `include-all-proxies`\nthat no filter, exclude filter or exclude type narrows; what filters and providers pick, including provider proxies\nthat carry their own `dialer-proxy`, is left to this guard.\n\n`ProxyGroup.fromJson` reads `include-all` as both `include-all-proxies` and `include-all-providers`, as the core does, so\nimport and quick edit keep it. They take in only what the config declares, which is what some group of the profile\nnames: its local proxies and the file or url profiles in a `use`. Setting one declares nothing more, so the app's other\nproxies and subscriptions stay out, and the core picks from the declared ones by the group's filters. Groups saved by\nv0.9.0 kept `include-all-proxies` in their stored `definition`, and it takes effect again.\n\nDesktop RPC accepts both a single event object and batched event lists. Android and desktop listener dispatch isolate\nlistener exceptions so one faulty observer does not prevent the remaining events/listeners from running.\n\n## User-Facing Core And Delay Feedback\n\n`CoreStatusButton` in `lib/views/dashboard/widgets/core_status_button.dart` is the desktop dashboard's status/restart\nsurface. It is shown only outside dashboard edit mode and only when `coreLib == null`:\n\n- Provider state remains authoritative. The widget keeps a separate display-only status so a fast\n  `connecting -> connected` transition still shows at least 600 milliseconds of progress instead of flashing.\n- The hold arms only after an observed transition to `connecting`; mounting while already connecting does not invent a new\n  delay. A real `disconnected` transition cancels the hold immediately so failure is never hidden, while a long-running\n  connecting state remains visible after the timer expires.\n- Taps during the display hold or while the provider is genuinely connecting are inert. Connected/disconnected taps show\n  the appropriate confirmation and delegate restart to `CoreAction`; the widget never starts Core directly.\n\nProxy delay testing follows the same failure-safe UI rule. `ProxiesAction._runDelayTests` marks its keys in\n`pendingDelayTestsProvider` and releases them in `finally`, and a test the Core does not answer leaves the last\nmeasurement in place; the delay-test bullets under Core API Safety in `.agents/rules.md` give the reasons. A key stays\n`queued` until the `TaskPool` actually runs it, so node cards animate a loader for at most `maxConcurrentDelayTests`\nnodes and show a static glyph for the rest; a loader on every queued node made fast scrolling drop frames. A group's delay\ntest button, the tab page's action and the list page's group header alike, spins while `delayTestingGroupsProvider`\nholds the group; `delayTestPageGroup` marks it, ignores a second request for the same group, and clears it in `finally`,\nso an RPC failure cannot leave the button spinning and remounting a header does not lose the state.\n\nThe dashboard's probe cards show a failure as still checking for five seconds after they see `proxied` turn true,\nthrough `ProbeStartHold` in `lib/views/dashboard/probe_start_hold.dart`: `ServiceStatusCard` holds the check on each\ntile, and `CurrentOutboundCard` holds the exit IP, whose refresh button stays inert meanwhile.\n`SetupAction.setRunning` flips the run time, and with it `proxied`, before the Core has brought its listeners up, so the\nprobe launched on the spot often fails, and the config re-apply that follows every start probes again moments later;\nwithout the hold a card flashes a failure and then takes it back. The hold is local display state like the connecting\nhold above: the cache still records the failure, the hold arms only on an observed transition, a result that is not a\nfailure shows at once, a stop cancels it, and a failure still standing when it ends is shown.\n\n## Route Consistency\n\nA probe result on the dashboard — the exit IP, a node's exit IP, a service check — either describes the route in\nforce or is being asked again. The mechanism has three parts.\n\n- The Core is the source of truth for the route. `core/route.go` keeps two counters. The epoch is a reset, which\n  no answer outlives: a config apply, an IPv6 switch, and a start (the listeners coming up and, on Android, the TUN\n  after them). The route version is a move, which may send a host to another exit: a Selector, URLTest or Fallback\n  pick changing, a proxy provider whose version changed, a rule set that loaded new content, a GeoIP or GeoSite\n  update, and a mode switch. `handleChangeProxy` re-reads the picks under `selectMu` and answers\n  `ChangeProxyResult{changed}`, so the connection reset only runs when the pick moved. A health check moving a\n  URLTest pick has no mihomo hook, so the picks are polled every second while the host watches (`watchRoute(true)`)\n  and the listeners are up; the same poll notices a provider or rule set that updated in the background, which in\n  this fork includes every provider an apply loads, since `loadProvider` returns before they finish. Every probe answer\n  carries the counters it started under and its hits: each host it dialed, the proxy it was pinned to, and the exit\n  it left through, read off the tracked chain or, for a connection that never reached the tracker, matched again.\n  An exit is the last hop with its server address, or a load balancer, which spreads connections without the route\n  moving. `checkRoutes` matches those hosts again through `tunnel.ResolveRoute`, a fork patch that runs the rules\n  without dialing, and reports per answer whether every exit held. `routeChanged` publishes the counters whenever\n  they differ from the last read. A start resets the epoch because `isStartProvider` flips before the Core is told,\n  so the probes sent at the tap leave ahead of the TUN routes and over DNS connections `handleStartListener` resets,\n  and an Android start acknowledges before the VPN exists. Outbound IP lookups are cancelled by the first lookup\n  under a newer epoch, which frees their probe slots; lookups under one epoch are left alone, since the routed\n  target while stopped and a pinned `DIRECT` send the same request.\n- `routeTrackerProvider` in `lib/providers/route_state.dart` merges that with the host's side: `proxied` (running and\n  not suspended), a host epoch that a connectivity event moves, and `synced`, which is false until `watchRoute` has\n  returned a snapshot and again after a Core disconnect. Nothing is fresh while unsynced, so a cache never judges\n  answers against counters it made up. The tracker subscribes when the first target is watched and releases the Core\n  when the last one goes; an answer newer than the state means an event was evicted, and the cache asks for a resync.\n  It also releases the Core while the app is hidden and reads it again on return, keeping `synced`: the Core and its\n  counters are the same ones, so the fresh snapshot invalidates only what moved. On Android the Core drops the watch\n  itself when the event listener is cleared, because an engine that goes away never sends `watchRoute(false)`.\n- `RoutedProbe` in `lib/providers/routed_probe.dart` is the one cache both `OutboundIpProbe` and `ServiceStatus` mix\n  in. A widget `watch`es the target it shows and `unwatch`es on the way out; only watched targets are probed, one\n  request in flight per target. An answer is stamped with the Core's counters, its hits and the host state at\n  launch. A reset (`RouteState.hasMovedSince`: the Core epoch, the host epoch or `proxied`) invalidates everything:\n  a stale watched entry shows as checking at once and is asked again after `probeMoveSettleDuration`, since a start\n  moves it three times within about 150 ms (`proxied`, then the listeners, then the Android TUN) and the user is\n  watching for the new address. A new route version keeps every answer on show and, once `probeSettleDuration` has\n  passed the next pick poll, sends their hits to `checkRoutes` in one call; only an answer whose exit moved is\n  dropped and asked again, and one that held takes the checked version. An answer that lands under an older version\n  than the state is held back as checking until its own check returns, so it never shows and then flips. An ask\n  that is not the user's or a reset's waits out `probeThrottleDuration` since the target's last probe, so a config\n  load whose providers, rule sets and url-test picks land over a few seconds moves an exit at most one probe more.\n  A `refresh` waits for neither, and an unwatched entry whose exit moved is dropped. Nothing launches or checks\n  while the app is hidden: what falls due waits for it to be shown.\n  Failures wait for `retry`, `refresh`, or the app resuming (`markResumed`), and\n  a service check that timed out is a failure that still shows its status. `refresh` is the user's own request and\n  runs for unwatched targets too: the service sheet reads the cache and never watches its rows, because one service\n  check is one to four full HTTPS fetches where a delay test is a single empty request.\n\n`ipQuality(ip)` in `lib/providers/ip_quality.dart` is keyed by the outbound IP found, so a route change reaches it as\na new IP and an unchanged IP reuses the result. A lookup races its sources the way the IP check does and takes the\nfirst one that determines a type. A success is saved to the `IpQualities` table and answers for that IP, across\nrestarts, until `ipQualityMaxAge` (three days) has passed; a shown result is asked again when it expires,\nand invalidating the provider (the sheet's check again) skips the saved record. A failure is not saved, and Riverpod's\nautomatic retry is off because a retry after an HTTP 429 spends the same per-outbound-IP quota again.\n\n## UI Layer\n\n`lib/ui/` holds the components and design tokens that know nothing of the app: shapes, surfaces, motion, colors, the\nscaffold, sheets, popups, lists and the glyphs under `lib/ui/icons/`. `lib/widgets/` holds the components built from\nthem that do: settings rows, subscription and profile pieces, anything that reads a provider or a model.\n`widgets/widgets.dart` re-exports `ui/ui.dart`, so app code imports either barrel.\n\n`test/lint/ui_layering_test.dart` walks the imports of every file under `lib/ui`. Nothing they reach may leave\n`lib/ui`, except `lib/common` files whose own imports stay out of the app, never the `common.dart` barrel, and none\nmay import Riverpod. A component that needs app state reads what the app provides above its navigator:\n\n- `ViewScope` (`lib/ui/view_scope.dart`): the window size, whether the layout is mobile, and the window controls\n  over a page. `AppViewScope` in `lib/manager/theme_manager.dart` fills it from `viewSizeProvider`,\n  `isMobileViewProvider` and `versionProvider`. It is an `InheritedModel`, so a reader of one value does not rebuild\n  when another changes; `context.isMobileView` reads it without listening.\n- `MeasureScope` (`lib/ui/measure.dart`): the `Measure` that `ThemeManager` also assigns to `globalState.measure`.\n- The `MediaQuery` text scaler that `ThemeManager` sets: the app text scale behind `.ap(context)`, `.mAp(context)` and\n  the `...Of(context)` paddings in `lib/ui/metrics.dart`. Reading it there makes a widget sized by them rebuild when the\n  scale changes; a global copy or a top-level final would keep the scale it was first read at.\n- `UiLocalizations` (`lib/ui/l10n.dart`): the strings the components show themselves. `AppUiLocalizations.delegate`\n  in `lib/common/app_localizations.dart` serves them from the ARB strings; a new one needs a getter on both.\n\nA test that builds its own `MaterialApp` registers `AppUiLocalizations.delegate` and returns `wrapAppScopes` from\n`test/helpers/test_app.dart` in its builder, as `TestApp` does.\n\n## Icons\n\n`lib/ui/icons/` holds every icon the app shows. Nothing in `lib/` uses the Material icon font, and\n`test/lint/material_icons_test.dart` fails on any `Icons.*` or `AnimatedIcons.*`, so every icon shares one stroke and\ncan animate its shape.\nA `Glyph` is a list of shapes on a 24-unit grid, stroked at `Glyph.strokeWidth` with round caps and joins and\nsuperellipse corners; a curve the primitive shapes cannot draw is a `GlyphPath`, SVG path data traced along the\nstroke's center. Each shape has a role that decides its filled form, the way SF Symbols and Fluent pair an\noutlined icon with its filled variant:\n\n- `body` fills;\n- `detail` is cut out of the fill, and a `solid` detail such as a dot is cut as an area;\n- `line` stays a stroke, for parts that belong to no body, like a handle or a link between nodes; it thickens from\n  `Glyph.strokeWidth` toward 2.2 as the glyph fills, so a glyph of lines alone still carries weight in a filled button.\n  A `solid` line, such as the dots of `AppGlyphs.more`, scales by the same ratio.\n\n`GlyphPainter` paints any point between the two forms. Each body fills inward from its outline, reaching half its\nshorter side, which no point of the body lies beyond, as `fill` reaches 1. Details thin away over the first half and\nreturn as cutouts over the second, so a detail is never a stroke and a cutout at once. The glyph is painted opaque into\none layer that carries the color's alpha: a translucent color, such as a disabled one, then stays even where strokes\ncross the fill, and a cutout clears only the glyph's own pixels. `GlyphIcon` shows a glyph at a fixed `fill`, taking\nits size, color, and fill from `IconTheme` unless it is given its own, as `Icon` does, and mirroring it in right-to-left\ntext when `Glyph.matchTextDirection` is set.\n`AnimatedGlyph` drives a `GlyphIcon` from its `filled` flag and pops briefly when it fills. A glyph whose shape follows\nsome other state, like the sidebar toggle that tracks the pane's width, is a builder in `AppGlyphs` such as\n`AppGlyphs.sidebar(progress)`. A new glyph goes in `AppGlyphs` and in the list `test/icons/glyph_painter_test.dart` runs\nthrough its checks; it is named for what it means in the app, one glyph per meaning, with SF Symbols and Fluent as the\nreference for the metaphor. Tests find one with `find.byGlyph` from `test/helpers/glyph_finders.dart`.\n\nIcons are outlined by default and filled inside a filled button: `IconButton.filled` and `.filledTonal`, and\n`FilledButton` in its tonal and icon forms draw their glyph with `fill: 1`, because an outline on a tinted\ncontainer reads weaker than the container it sits in. A glyph with no body only thickens its lines and still takes\n`fill: 1`, so the rule holds if the glyph later gains one. `test/lint/filled_button_glyphs_test.dart` checks every\n`GlyphIcon` written inside such a button; an icon that reaches the button from elsewhere, as through a helper that picks\nthe button variant, gets its fill by hand or from `IconThemeData.fill`. Everywhere else fill marks state only: the\nselected destination, a pinned window, a lit torch. The phone's floating navigation bar is the one exception: like an iOS\ntab bar it fills every destination and marks the selected one by colour alone.\n\nFramework widgets that draw their own icon take it from the theme: the `ActionIconThemeData` in `lib/application.dart`\nsupplies the back and close buttons, with `AppGlyphs.backFor` picking the platform's back glyph, and a `DropdownMenu`\nis given its chevrons. A framework widget added later that paints a Material icon needs the same treatment.\n\nEmpty states draw no image assets either. `NullStatus` in `lib/ui/null_status.dart` shows the glyph for its\nmeaning filled on a Material 3 Expressive shape from `material_new_shapes`, with a few accent marks, and enters with\none of three choreographies picked at random: the shape spins in, morphs out of a circle, or springs in while the\naccents orbit into place. The glyph takes `onPrimaryContainer`, not `primary`: the `content` scheme variant can leave\n`primaryContainer` as light as `primary` in dark mode.\n\n`CaptionIcon` is the one icon that does not follow the glyph grid: the window caption buttons keep the one pixel,\npixel-aligned stroke of Windows' 10-pixel caption glyphs on every platform.\n\n## App Bar Actions\n\n`CommonScaffold` keeps at most three buttons in sight in its bar, the overflow menu button included. A page hands it\nwhat may fold as data: `primaryAction`, `iconActions`, `menuItems`, and `searchActions` while searching; custom widgets\npassed through `actions` never fold. Past three, two buttons stay beside the overflow menu: the search button when\nthere is one, then foldable actions, primary action first;\neverything else goes into one overflow menu, primary action first, and a folded action that is loading shows as a\ndisabled item. The bar reads as one button group of two beside a single button: the overflow menu button when\nsomething folds, otherwise the last action, so the proxies list shows a search and delay test group beside the menu.\nThe bar holds `primaryAction` everywhere, a phone's home page above the fixed navigation bar included, and nothing in\nthe app floats an action button: a secondary a","isInternal":false,"tokens":11346,"sizeBytes":50006},{"name":"SKILL.md","path":".agents/skills/core-platform/SKILL.md","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/.agents/skills/core-platform/SKILL.md","title":"core-platform","category":"anthropic-skill","format":"markdown","content":"---\nname: core-platform\ndescription: 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.\n---\n\n# Core And Platform\n\n## When To Use\n\nUse this for changes touching `lib/core/`, `lib/manager/`, `core/`, `services/helper/`, Android app/service modules,\nbuild hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior.\n\n## Read First\n\n- `.agents/rules.md`: Core API Safety and Lifecycle Rules are binding. They carry the mihomo-fork patch budget, the\n  JSON envelope and `core/message.go` queue rules, gomobile callback rules, `BootGuard`, tray, and SSID rules, and every\n  lifecycle invariant (latest intent wins, desktop ownership through a `CoreProcessLease`, terminal `close()`,\n  optimistic Android commands arbitrated by `ServiceState`, callbacks are not user intent, `goAsync()` finishes once).\n- `.agents/setting-changes.md`: a setting the Core or the Android VPN reads is patched live, re-applied with the\n  profile, or left to a restart the user confirms; it says which, and what adding a setting to each path takes.\n- `.agents/architecture.md`: Core Integration, Lifecycle Ownership And Convergence, Core Protocol And Event Delivery,\n  Safe Mode, Build System, and Rust Helper Service describe the current mechanisms, including the Helper protocol,\n  the applied/coalesced/superseded outcomes, and the unconfirmed-exit lease.\n\n## Owners\n\nEvery behavior here has one authoritative owner, and a change lands there:\n\n- Shared facade/protocol: `lib/core/controller.dart`, `lib/core/interface.dart`, and `lib/core/method.dart`.\n- Android Core connection: `lib/core/lib.dart`, `lib/plugins/service.dart`, and Android `ServicePlugin`.\n- Android start/stop intent: `ServiceState`; binding/process-time bookkeeping: `ServiceController`.\n- Desktop composition: `lib/core/service.dart`; lifecycle/process ownership: `lib/core/desktop/lifecycle.dart`.\n- Desktop IPC/RPC: `lib/core/desktop/transport.dart` and `lib/core/desktop/rpc_client.dart`.\n- Desktop launch ownership: `lib/core/desktop/launcher.dart`; Windows and Linux Helper HTTP contract:\n  `lib/core/desktop/helper_client.dart` and `services/helper/`.\n- Flutter orchestration: `lib/providers/actions/core.dart` and `system.dart`; application exit:\n  `SystemExitCoordinator`; UI/event observation: `lib/manager/`.\n\nAn owner has more entry paths than the UI: provider calls, Quick Settings, notification actions, Always-on VPN, revoke\ncallbacks, application exit, and crash/disconnect recovery all reach it. Always-on startup is explicit through\n`VPN_START_REQUESTED` and revoke through `VPN_REVOKED`; service creation or destruction is never read as intent.\n\n## Verification\n\nFocused tests live at the narrowest layer, and the matching commands are in `.agents/commands.md`:\n\n- Desktop lifecycle/transport/RPC: `test/core/desktop/` plus `test/core/service_test.dart`.\n- Cross-language envelopes/events: `test/core/protocol_contract_test.dart` and `CGO_ENABLED=0 go test .`.\n- Provider/exit convergence: `test/providers/action_test.dart` and `test/providers/system_action_test.dart`.\n- Android Kotlin: compile each touched Gradle module with JDK 17.\n- Helper: `cargo fmt --check` and `cargo test`; the `windows-service` feature only builds on Windows.\n\nState host gaps explicitly. Always-on VPN, VPN permission, system revoke, named-pipe peer identity, polkit and systemd\non Linux, and Windows Service Control Manager behavior need their real platform even when portable tests pass.\n\n## Pitfalls\n\n- A `200` from Helper `/stop` means the Core is gone. When termination cannot be confirmed the Helper keeps the child\n  and answers `coreStopFailed`, and `/start` reports the same code instead of spawning a replacement. Keep that code\n  out of the Dart pre-spawn fallback set in `helper_client.dart`, or the direct launch races a Core the Helper still\n  owns.\n- The Helper owns executable integrity: Core SHA256 validation and the protocol stay identical across Flutter build\n  modes, so a debug build is not exempt.\n- `plugins/setup/` is a build harness, not a Dart API plugin, and its hooks can trigger Go or Rust compilation\n  indirectly through Flutter platform builds; a missing toolchain fails `flutter test`, not only `flutter build`.\n","frontmatter":{"name":"core-platform","description":"Use when changing FlClash Core integration, lifecycle/process ownership, Go event delivery, Android services, desktop IPC, platform managers, build hooks, system proxy, tray, VPN/TUN, or the Windows or Linux Helper."},"isInternal":false,"tokens":963,"sizeBytes":4377},{"name":"SKILL.md","path":".agents/skills/localization/SKILL.md","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/.agents/skills/localization/SKILL.md","title":"localization","category":"anthropic-skill","format":"markdown","content":"---\nname: localization\ndescription: Use when changing FlClash UI text, scanning for hardcoded Chinese strings, updating ARB localization, or fixing generated locale output in this repository.\n---\n\n# Localization\n\n## When To Use\n\nUse this for localization work in this repository, especially hardcoded UI text in `lib/`, ARB updates, missing translations, or generated `lib/l10n/` output that does not match source ARB values.\n\nDo not use this for README translation sync or manual edits to generated localization Dart.\n\n## Workflow\n\n1. Scan user-facing Dart text before opening many files. `flutter_intl` reads `arb/` and writes `lib/l10n/`:\n\n   ```bash\n   rg -n \"[\\\\p{Han}]\" lib -g '!lib/l10n/intl/**' -g '!lib/**/generated/**'\n   ```\n\n2. Inspect the smallest relevant call sites and nearby ARB keys.\n3. Add or update every source ARB:\n   - `arb/intl_en.arb`\n   - `arb/intl_zh_CN.arb`\n   - `arb/intl_ja.arb`\n   - `arb/intl_ru.arb`\n   - `arb/intl_fr.arb`\n   - `arb/intl_es.arb`\n   - `arb/intl_ko.arb`\n4. Replace inline strings with existing project accessors:\n   - Widgets with `BuildContext`: `context.appLocalizations.key` from `lib/common/context.dart`.\n   - Controllers/providers/non-widget code: `currentAppLocalizations.key` from `lib/common/app_localizations.dart`.\n5. Regenerate:\n\n   ```bash\n   dart run intl_utils:generate\n   ```\n\n6. Run `flutter analyze --no-fatal-infos` and `flutter test test/l10n test/lint/dynamic_message_key_test.dart`. The\n   lint test checks that every locale carries the same keys and that no `Intl.message` builds a key at runtime; labels\n   for enum values go through the switches in `lib/common/l10n_labels.dart`, as the ARB section of `.agents/rules.md`\n   explains.\n7. Re-run the Han-text scan for targeted strings.\n\n## Pitfalls\n\n- A locale that still shows English lacks the key or carries an English value in its source ARB; fix that ARB and\n  regenerate. Do not edit generated Dart.\n- Ignore `lib/l10n/intl/**` and `lib/**/generated/**` during text scans.\n","frontmatter":{"name":"localization","description":"Use when changing FlClash UI text, scanning for hardcoded Chinese strings, updating ARB localization, or fixing generated locale output in this repository."},"isInternal":false,"tokens":520,"sizeBytes":1999},{"name":"SKILL.md","path":".agents/skills/pre-commit-quality-review/SKILL.md","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/.agents/skills/pre-commit-quality-review/SKILL.md","title":"pre-commit-quality-review","category":"anthropic-skill","format":"markdown","content":"---\nname: pre-commit-quality-review\ndescription: Use when the user asks to commit, amend, or squash code, or requests a pre-commit structural quality review.\n---\n\n# Pre-commit Quality Review\n\nCheck that iterative edits converged into a clear, cohesive, maintainable implementation before they become a commit,\nso later code review is not spent on basic structure. `AGENTS.md` and `.agents/rules.md` apply throughout. This skill\ngrants no authority: commit, push, and history rewrites stay under the user's existing authorization, and silence is\nnot consent.\n\n## Triage\n\nMost commits stop here. Decide from the change summary, the diff, and the local context it needs; never start with a\nrepository-wide scan.\n\n- **Skip** formatting, mechanical renames, generated output, copy changes, and simple local fixes. They go through the\n  normal commit flow without extra confirmation. Say the review was skipped, never that it passed.\n- **Review** when a core implementation is replaced; when responsibilities, dependency direction, or public abstractions\n  change materially; when accumulated patches added branches, duplicate paths, or indirection; or when new code draws\n  responsibility boundaries. A small change can move a critical responsibility, so line count is not a trigger.\n- An explicit structural review request bypasses triage.\n\nThe snapshot under review is the intended commit compared with its baseline:\n\n| Operation | Baseline |\n|---|---|\n| Regular commit | `HEAD` |\n| Amend | Parent of the commit being replaced |\n| Squash | Version preceding the agreed range |\n| Initial commit | Empty tree |\n\nAsk when the range or parent is materially ambiguous instead of assuming the whole branch. Read staged versions of\npartially staged files; an unstaged fix is not part of the pending commit. Preserve unrelated changes and partial\nstaging.\n\n## Review the Final Structure\n\nScope the review to the responsibility boundaries this change touched. Read the complete final implementation of the\naffected units and compare before and after, not only the last patch. Report issues this change introduced or\nworsened; untouched historical debt is not a pre-commit requirement. Follow a call or dependency path only as far as a\nconcrete concern needs, and disclose missing essential context rather than claiming a complete pass.\n\nFocus on:\n\n- **Responsibilities and cohesion:** units mixing responsibilities that change independently; orchestration tangled\n  with implementation detail; responsibilities in layers that should not own them.\n- **Dependencies and abstractions:** reversed or circular dependencies, forwarding layers, abstractions that isolate\n  nothing; similar names or interfaces hiding different responsibilities.\n- **Complexity and readability:** nested conditions, repeated fallbacks, boolean parameter combinations, mutually\n  dependent helpers; names and decomposition that do not reduce reading effort.\n- **Duplication and consistency:** reimplemented existing capabilities, parallel implementations or duplicate state,\n  scattered ownership of error handling, cleanup, or state updates.\n- **Remnants:** obsolete branches, compatibility layers, unused parameters, commented-out code, comments the rewrite\n  invalidated; coupling that makes existing test boundaries hard to use.\n\nNot findings: length, a missing design pattern, naming taste, or anything without code evidence and a real\nmaintenance cost. Do not add cross-layer coupling to remove a few similar lines, rewrite stable code for uniformity,\nor demand abstractions for speculative needs.\n\nBusiness logic, UI behavior, performance, security, platform compatibility, and full functional review are out of\nscope. Report an incidentally found serious defect separately through the normal defect workflow, without hiding it or\nwidening this review.\n\n## Findings and the User's Decision\n\nA finding qualifies only when all three hold:\n\n- Before-and-after code and the necessary call relationships show this change introduced or worsened it.\n- The maintenance cost is concrete, such as updating one rule in two places or touching unrelated layers for one\n  responsibility. Clarity or elegance alone is not enough.\n- A minimal proposal fits existing constraints, with a benefit not offset by new coupling, indirection, or scope.\n\nWith no qualifying finding, state the reviewed scope and conclusion in a sentence or two and continue under the\nexisting commit authorization. Do not ask for confirmation because a review happened, and do not collect optional\nimprovements into a suggestion list.\n\nWith qualifying findings, finish the read-only review and independent checks first, merge findings that share a cause,\nand give for each: file, symbol, and location with how this change introduced it; the concrete maintenance cost; the\nsmallest proposal with its scope and any behavioral impact. Then **pause before creating the commit and ask whether to\noptimize first; do not edit or commit until the user answers.** Attribute the pause to this skill by file path so the\nuser recognizes their configured workflow, for example: \"The structural issues above support consolidating the\nduplicate state-update paths. Should I make that change before committing?\"\n\nExisting explicit authorization for these findings is enough to act without asking again. The user may accept all,\nsome, or none. Keep accepted findings and tradeoffs in the task context only, and do not re-ask because files were\nrestaged, a hash changed, or unrelated edits landed; reassess only when relevant code changes invalidate the tradeoff.\n\n## Resolve and Finish\n\nOnce authorized, make the smallest adjustments that resolve the accepted findings while preserving behavior and task\nscope; explain separately if either must change. Verify only the accepted findings and the direct effects of the\nadjustments, without another search for improvements. Raise a new finding only with evidence that meets the threshold\nand exceeds the existing authorization.\n\nBefore committing, recheck status and the actual staged diff so the pending snapshot matches what was reviewed, and\nreview only the delta of later edits. Run the checks `AGENTS.md` requires for the change: `flutter pub get`,\n`flutter analyze --no-fatal-infos`, and the covering `flutter test` suites listed in `.agents/commands.md`. Reuse\nresults that are still valid, rerun those a later edit invalidated, and never present results from an earlier snapshot\nas verification of the staged one. The `comment-density` and `commit-msg` hooks in `.agents/rules.md` still gate the\ncommit.\n\nReport the reviewed scope, completed improvements or accepted tradeoffs, check results, and any verification limits.\nClaim the commit is done only after it exists.\n","frontmatter":{"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."},"isInternal":false,"tokens":1305,"sizeBytes":6734},{"name":"SKILL.md","path":".agents/skills/ui-work/SKILL.md","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/.agents/skills/ui-work/SKILL.md","title":"ui-work","category":"anthropic-skill","format":"markdown","content":"---\nname: ui-work\ndescription: 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.\n---\n\n# UI Work\n\n## When To Use\n\nUse this for user-facing Flutter UI changes in `lib/`, including widgets, screens, navigation surfaces, settings rows,\ndialogs, and interaction behavior.\n\n## Conventions\n\n- Reuse the patterns of nearby widgets, providers, notifiers, and helpers before adding an abstraction, and keep to the\n  look the rest of the app has: Material You from `material_ui` as the base, with Apple's interaction conventions\n  (continuous corners, sheets that snap between detents, a floating tab bar) built on Material widgets rather than\n  Cupertino ones. Do not introduce a visual system for one screen or rewrite a layout the request does not need.\n- `.agents/rules.md` is binding here: the lint set (`child:` last, `const`, final locals), the superellipse corner API,\n  and the widget-test facts under Testing Rules (`pumpAndSettle` never returns on `EditorView`, `dispose()` must not\n  await, auto-dispose providers need a container-level hold, platform decisions are passed in as parameters).\n- A component that reads no provider, model or app string goes in `lib/ui/`; one built for an app feature goes in\n  `lib/widgets/`. The UI Layer section of `.agents/architecture.md` says how a `lib/ui` component gets app state.\n- Settings screens are built from the `ConfigToggleItem` family in `lib/widgets/config_item.dart`; the Settings Rows\n  section of `.agents/architecture.md` says when a file-local helper or a named widget is warranted.\n- Every icon lives in `lib/ui/icons/`, never the Material `Icons` font: reuse or add a glyph in `AppGlyphs` and show it\n  with `GlyphIcon`, or with `AnimatedGlyph` where it fills on selection. Glyphs are outlined, except inside a filled\n  button, where they take `fill: 1`. The Icons section of `.agents/architecture.md` covers shape roles, the stroke, the\n  fill animation, and when to fill.\n- Text sizes and weights come from `AppTextStyle` in `lib/ui/typography.dart`, Apple's iOS Dynamic Type scale, never a\n  Material `TextTheme` slot, a bare `fontSize`, or a `fontWeight`; the Typography section of `.agents/rules.md` has the\n  rule.\n- Modals dim what they cover and never blur it: sheets with the Material 3 scrim (`ColorScheme.modalScrim`, `scrim` at\n  32%), alerts with `ColorScheme.alertScrim`, the lighter dim iOS 27 puts behind an alert. Every modal brings its own\n  scrim, including one opened from inside another, so do not make a barrier transparent to keep scrims from stacking.\n- A sheet's size belongs to its route, never to its content or call site: a bottom sheet rests at\n  `SheetProps.detents`, a side sheet is as tall as the window and as wide as `ModalSideSheetRoute` makes it from\n  the window width (`lib/ui/side_sheet.dart`, pinned by `test/widgets/side_sheet_width_test.dart`), and a form sheet\n  is a card `FormSheetRoute` caps in width and height (`lib/ui/form_sheet.dart`). Do not wrap a sheet's content in a\n  fixed width or height to resize one sheet.\n- A form with more than one value opens with `showFormSheet` and lays its values out in `FormRow`s, never as labeled\n  fields in a `CommonDialog`; a prompt for one value stays an alert. Forms in `.agents/architecture.md` has the rule.\n- A modal scrolls only once the window and keyboard leave it no room, and then through one scroll view: `CommonDialog`\n  caps its body at nothing but the space it is given, and a multi-line field in a dialog or sheet grows with its text\n  (`maxLines: null`) rather than scrolling inside a line count. With the keyboard up, a bottom sheet keeps what it\n  shows above the keyboard, rising past its tallest detent as far as a peek below the top (`_top` in\n  `lib/ui/snap_sheet.dart`).\n- The keyboard animates `MediaQuery.viewInsets` every frame. Read MediaQuery by aspect (`paddingOf`, `viewInsetsOf`)\n  rather than `MediaQuery.of`, and wrap the page of a new route type in `KeyboardInsetHold`; Keyboard Insets in\n  `.agents/architecture.md` has the reasons.\n- A page loads what it shows as it mounts, never once its route has settled; what waits out a transition is work\n  that repeats, such as a live feed's refreshes (`RouteMotionHoldMixin`), and the rows past the viewport\n  (`arrivalScrollCacheExtent`). Page Arrival in `.agents/architecture.md` has the rule.\n- A page that waits on its data shows nothing until all of it is ready (`NullStatusSwitcher.isLoading`), and a load\n  that can run long adds its illustration as `loadingStatus`, never a skeleton screen; Page Arrival in\n  `.agents/architecture.md` has the rule.\n- App bar buttons reach `CommonScaffold` as data (`primaryAction`, `iconActions`, `menuItems`) so it can fold them into\n  its overflow menu; App Bar Actions in `.agents/architecture.md` has the rule.\n- A row's secondary actions open from a context menu, never a trailing more button; a list whose rows have one\n  reorders in a sort mode, any other by a long press on the row. Row Actions And Sort Mode in `.agents/architecture.md`\n  has the rule.\n- Quick edit belongs only on a page that edits a whole list or section, or one isolated item; never on one that edits a\n  part of something other settings also write. Quick Edit in `.agents/architecture.md` has the rule.\n- A page or sheet opens on the navigator of the context it comes from, never on the root navigator unless Where A\n  Route Opens in `.agents/architecture.md` allows it.\n- User-facing text goes through ARB; use `localization` when text changes are non-trivial.\n- An asynchronous control keeps four things apart: authoritative provider/domain state; display-only state such as a\n  minimum progress duration; tap policy while work or a display hold is active; and failure/disposal cleanup, in\n  `finally` for animations and timers. `CoreStatusButton` and the proxies delay test action are the reference implementations,\n  described under User-Facing Core And Delay Feedback in `.agents/architecture.md`.\n\n## Corner Radii\n\nAll corner radii come from `lib/ui/shape.dart`: a token, a derived value, or a named off-scale value passed to its\nbuilders. Never write a bare radius literal at a call site.\n\nThe scale is picked by the component's **shortest side**. A radius that reads as a soft card at 64 dp tall\nreads as a pill at 24 and as a square at 400, so one radius everywhere is the wrong kind of consistency.\n\n| Token | Value | Shortest side | Use |\n| --- | --- | --- | --- |\n| `none` | 0 | - | square edges, and the flat side of a grouped run |\n| `xs` | 4 | inset blocks | tiles clipped inside an already rounded surface |\n| `sm` | 8 | up to 48 | chips, thumbs, swatches, small square tiles |\n| `md` | 16 | up to 64 | interactive chrome: inputs, menus, buttons, popups |\n| `lg` | 20 | 64 to 180 | mid-size cards: dashboard tiles, proxy node cards |\n| `xl` | 24 | full-bleed | anything spanning the whole width: list rows, grouped runs, group headers |\n| `xxl` | 28 | over 200 | sheets, full-screen containers |\n| `alert` | 34 | - | alerts, the corner of iOS 27's UIAlertController; on `AppCorner` only, passed to the builders |\n| `full` | 1000 | - | pills and circles: buttons, tracks, indicators, progress, avatars |\n\n- `AppCorner` holds the scale as `double`; `AppRadius` mirrors it as `BorderRadius` with `all`, `top`, and `vertical`\n  builders; `AppShape` mirrors it as `RoundedSuperellipseBorder`, `full` included, plus `circle`, `input`, and the\n  `all`/`top`/`vertical`/`of` builders. Use `AppShape.full` for pills, never `StadiumBorder`; the Corner Radius\n  section of `.agents/rules.md` explains why and lists the superellipse API per layer.\n- `AppCorner.fit(shortestSide)` snaps to the largest token at or under one third of the shortest side. Use it whenever\n  the size comes from a `LayoutBuilder` or scales with text.\n- `ThemeData.withAppShapes` in `lib/ui/shape.dart` already applies the scale to card, dialog, bottom sheet, snack\n  bar, chip, menu, input, FAB, navigation indicator, progress, and the filled, text, outlined, elevated, and icon\n  button themes. Do not restate those shapes at call sites, and\n  leave `InputDecoration.border` unset so inputs inherit `AppShape.input`.\n- Colors of surfaces follow `.agents/surfaces.md`: fills by `SurfaceLevel`, no structural borders, and a selected\n  item keeping its fill under a `SelectionCheck` or `SelectionBadge`, with no container role on a card or row.\n\n`xl` and `lg` exist because one token cannot serve both size classes. A full-bleed surface is wide and short, so its\ncorner must be larger than its height alone suggests or it stops reading as a card; everything full-bleed shares `xl`\n(the profiles card, the outer corners of a grouped run in `DecorationListItem`, the proxy\ngroup card in `lib/views/proxies/list.dart`) and does not use `fit()`. This is the one deliberate overshoot of the\none-third rule; the value stays under half a standard row so nothing clamps and every full-bleed surface renders the\nsame radius. `lg` is for mid-size cards that are not full-bleed: a proxy node card in the tab view at\n`ProxyCardType.compact` is only about 64 tall, which caps its radius at 21, so `xl` would break on a setting the user\ncan change at any time while `lg` clears both heights and the smallest dashboard tile.\n\nOutlined inputs use `AppInputBorder`, not `ShapedInputBorder` from `package:material_ui`. The package border subtracts\nthe floating label's notch as a flat band along the top edge, which takes the corner curve with it at any radius from 8\nup. `AppInputBorder` clips the notch region and paints the full superellipse through it, as Flutter's own\n`OutlineInputBorder` does by shortening the arc. It needs no `contentPadding` compensation.\n\nNested radii are derived, never tokens: concentric corners need `outer = inner + inset`, so name the inset and compute\nthe outer value, as `_cardRadius` in `lib/ui/popup.dart` does. Adding a token for one of these is how a scale grows without\nbound. A card that also stands on its own elsewhere keeps its token when nested: the proxy node cards inside a proxies\nlist group stay `lg` like those in the tab view, since deriving them from the group card's `xl` left them at 14, smaller\nthan every other card in the app.\n\nA component the scale does not fit keeps its own value instead of growing the scale: name it once in the file and pass\nit to the `all`/`top`/`vertical` builders, as `_itemShape` in `lib/ui/sidebar.dart` does with 10, between the\nchip-sized `sm` and the near-pill `md` on a 40 tall row. Reach for this only once the table and `fit()` are ruled out.\n\n## Verification\n\n- A behavior change gets a focused widget test covering rendering states, taps, scrolling, and empty/error states,\n  including the exception path of any async action. Run `flutter analyze` and `flutter test test/widgets/` plus the\n  tests of the touched view.\n- The scans in `test/lint/` fail on the common UI mistakes and run as part of `flutter test`: an `IconButton` whose\n  icon is an icon needs a `tooltip`; a field that constructs its own controller, notifier, or focus node is released in\n  the same file; a public declaration nothing references is dead. `.agents/rules.md` explains each rule and its\n  allowlist.\n\n## Pitfalls\n\n- Do not mutate provider/domain state merely to smooth a transition. Keep presentation holds local and let real errors\n  bypass them immediately.\n- Do not leave loading animations active when callbacks throw.\n- Do not manually edit generated localization or provider files.\n","frontmatter":{"name":"ui-work","description":"Use when changing FlClash Flutter UI, widgets, screens, Material You styling, icons or glyphs, shapes and corner radii, navigation surfaces, async feedback, or user-facing interactions."},"isInternal":false,"tokens":2810,"sizeBytes":11604},{"name":"analysis_options.yaml","path":"plugins/code_forge/analysis_options.yaml","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/code_forge/analysis_options.yaml","title":"Code_forge Skill","category":"anthropic-skill","format":"yaml","content":"# Code_forge Skill\n\nPath: `plugins/code_forge/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/code_forge/analysis_options.yaml)","tokens":300,"sizeBytes":197},{"name":"pubspec.yaml","path":"plugins/code_forge/pubspec.yaml","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/code_forge/pubspec.yaml","title":"Code_forge Skill","category":"anthropic-skill","format":"yaml","content":"# Code_forge Skill\n\nPath: `plugins/code_forge/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/code_forge/pubspec.yaml)","tokens":300,"sizeBytes":179},{"name":"analysis_options.yaml","path":"plugins/proxy/analysis_options.yaml","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/analysis_options.yaml","title":"Proxy Skill","category":"anthropic-skill","format":"yaml","content":"# Proxy Skill\n\nPath: `plugins/proxy/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/analysis_options.yaml)","tokens":300,"sizeBytes":182},{"name":"CHANGELOG.md","path":"plugins/proxy/CHANGELOG.md","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/CHANGELOG.md","title":"Proxy Skill","category":"anthropic-skill","format":"markdown","content":"# Proxy Skill\n\nPath: `plugins/proxy/CHANGELOG.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/CHANGELOG.md)","tokens":300,"sizeBytes":164},{"name":"pubspec.yaml","path":"plugins/proxy/pubspec.yaml","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/pubspec.yaml","title":"Proxy Skill","category":"anthropic-skill","format":"yaml","content":"# Proxy Skill\n\nPath: `plugins/proxy/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/pubspec.yaml)","tokens":300,"sizeBytes":164},{"name":"settings.json","path":"plugins/proxy/windows/.vscode/settings.json","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/windows/.vscode/settings.json","title":".vscode Skill","category":"anthropic-skill","format":"json","content":"# .vscode Skill\n\nPath: `plugins/proxy/windows/.vscode/settings.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/windows/.vscode/settings.json)","tokens":300,"sizeBytes":200},{"name":"analysis_options.yaml","path":"plugins/rust_api/analysis_options.yaml","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/analysis_options.yaml","title":"Rust_api Skill","category":"anthropic-skill","format":"yaml","content":"# Rust_api Skill\n\nPath: `plugins/rust_api/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/analysis_options.yaml)","tokens":300,"sizeBytes":191},{"name":"CHANGELOG.md","path":"plugins/rust_api/CHANGELOG.md","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/CHANGELOG.md","title":"Rust_api Skill","category":"anthropic-skill","format":"markdown","content":"# Rust_api Skill\n\nPath: `plugins/rust_api/CHANGELOG.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/CHANGELOG.md)","tokens":300,"sizeBytes":173},{"name":"CLAUDE.md","path":"plugins/rust_api/CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/CLAUDE.md","title":"Rust_api Skill","category":"anthropic-skill","format":"markdown","content":"# Rust_api Skill\n\nPath: `plugins/rust_api/CLAUDE.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/CLAUDE.md)","tokens":300,"sizeBytes":167},{"name":"flutter_rust_bridge.yaml","path":"plugins/rust_api/flutter_rust_bridge.yaml","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/flutter_rust_bridge.yaml","title":"Rust_api Skill","category":"anthropic-skill","format":"yaml","content":"# Rust_api Skill\n\nPath: `plugins/rust_api/flutter_rust_bridge.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/flutter_rust_bridge.yaml)","tokens":300,"sizeBytes":197},{"name":"pubspec.yaml","path":"plugins/rust_api/pubspec.yaml","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/pubspec.yaml","title":"Rust_api Skill","category":"anthropic-skill","format":"yaml","content":"# Rust_api Skill\n\nPath: `plugins/rust_api/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/pubspec.yaml)","tokens":300,"sizeBytes":173},{"name":"profile_config.json","path":"plugins/rust_api/rust/tests/fixtures/profile_config.json","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/rust/tests/fixtures/profile_config.json","title":"Fixtures Skill","category":"anthropic-skill","format":"json","content":"# Fixtures Skill\n\nPath: `plugins/rust_api/rust/tests/fixtures/profile_config.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/rust/tests/fixtures/profile_config.json)","tokens":300,"sizeBytes":227},{"name":"analysis_options.yaml","path":"plugins/setup/analysis_options.yaml","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/setup/analysis_options.yaml","title":"Setup Skill","category":"anthropic-skill","format":"yaml","content":"# Setup Skill\n\nPath: `plugins/setup/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/setup/analysis_options.yaml)","tokens":300,"sizeBytes":182},{"name":"pubspec.yaml","path":"plugins/setup/pubspec.yaml","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/setup/pubspec.yaml","title":"Setup Skill","category":"anthropic-skill","format":"yaml","content":"# Setup Skill\n\nPath: `plugins/setup/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/setup/pubspec.yaml)","tokens":300,"sizeBytes":164},{"name":"analysis_options.yaml","path":"plugins/setup/setup_hooks/analysis_options.yaml","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/setup/setup_hooks/analysis_options.yaml","title":"Setup_hooks Skill","category":"anthropic-skill","format":"yaml","content":"# Setup_hooks Skill\n\nPath: `plugins/setup/setup_hooks/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/setup/setup_hooks/analysis_options.yaml)","tokens":300,"sizeBytes":212},{"name":"pubspec.yaml","path":"plugins/setup/setup_hooks/pubspec.yaml","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/setup/setup_hooks/pubspec.yaml","title":"Setup_hooks Skill","category":"anthropic-skill","format":"yaml","content":"# Setup_hooks Skill\n\nPath: `plugins/setup/setup_hooks/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/setup/setup_hooks/pubspec.yaml)","tokens":300,"sizeBytes":194},{"name":"analysis_options.yaml","path":"plugins/tray/analysis_options.yaml","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/tray/analysis_options.yaml","title":"Tray Skill","category":"anthropic-skill","format":"yaml","content":"# Tray Skill\n\nPath: `plugins/tray/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/tray/analysis_options.yaml)","tokens":300,"sizeBytes":179},{"name":"pubspec.yaml","path":"plugins/tray/pubspec.yaml","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/tray/pubspec.yaml","title":"Tray Skill","category":"anthropic-skill","format":"yaml","content":"# Tray Skill\n\nPath: `plugins/tray/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/tray/pubspec.yaml)","tokens":300,"sizeBytes":161},{"name":"analysis_options.yaml","path":"plugins/wifi_ssid/analysis_options.yaml","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/wifi_ssid/analysis_options.yaml","title":"Wifi_ssid Skill","category":"anthropic-skill","format":"yaml","content":"# Wifi_ssid Skill\n\nPath: `plugins/wifi_ssid/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/wifi_ssid/analysis_options.yaml)","tokens":300,"sizeBytes":194},{"name":"pubspec.yaml","path":"plugins/wifi_ssid/pubspec.yaml","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/wifi_ssid/pubspec.yaml","title":"Wifi_ssid Skill","category":"anthropic-skill","format":"yaml","content":"# Wifi_ssid Skill\n\nPath: `plugins/wifi_ssid/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/wifi_ssid/pubspec.yaml)","tokens":300,"sizeBytes":176},{"name":"analysis_options.yaml","path":"plugins/window/analysis_options.yaml","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/window/analysis_options.yaml","title":"Window Skill","category":"anthropic-skill","format":"yaml","content":"# Window Skill\n\nPath: `plugins/window/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/window/analysis_options.yaml)","tokens":300,"sizeBytes":185},{"name":"pubspec.yaml","path":"plugins/window/pubspec.yaml","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/window/pubspec.yaml","title":"Window Skill","category":"anthropic-skill","format":"yaml","content":"# Window Skill\n\nPath: `plugins/window/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/window/pubspec.yaml)","tokens":300,"sizeBytes":167},{"name":"core-platform","path":".claude/skills/core-platform","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/.claude/skills/core-platform","title":"Claude Directive: core-platform","category":"claude-rule","format":"markdown","content":"../../.agents/skills/core-platform","isInternal":false,"tokens":7,"sizeBytes":34},{"name":"localization","path":".claude/skills/localization","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/.claude/skills/localization","title":"Claude Directive: localization","category":"claude-rule","format":"markdown","content":"../../.agents/skills/localization","isInternal":false,"tokens":7,"sizeBytes":33},{"name":"pre-commit-quality-review","path":".claude/skills/pre-commit-quality-review","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/.claude/skills/pre-commit-quality-review","title":"Claude Directive: pre-commit-quality-review","category":"claude-rule","format":"markdown","content":"../../.agents/skills/pre-commit-quality-review","isInternal":false,"tokens":9,"sizeBytes":46},{"name":"ui-work","path":".claude/skills/ui-work","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/.claude/skills/ui-work","title":"Claude Directive: ui-work","category":"claude-rule","format":"markdown","content":"# Claude Directive: ui-work\n\nPath: `.claude/skills/ui-work`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/.claude/skills/ui-work)","tokens":300,"sizeBytes":170},{"name":"README.md","path":"plugins/code_forge/README.md","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/code_forge/README.md","title":"code_forge Documentation","category":"plugin-manifest","format":"markdown","content":"# code_forge Documentation\n\nPath: `plugins/code_forge/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/code_forge/README.md)","tokens":300,"sizeBytes":181},{"name":"README.md","path":"plugins/proxy/README.md","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/README.md","title":"proxy Documentation","category":"plugin-manifest","format":"markdown","content":"# proxy Documentation\n\nPath: `plugins/proxy/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/README.md)","tokens":300,"sizeBytes":166},{"name":"README.md","path":"plugins/rust_api/README.md","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/README.md","title":"rust_api Documentation","category":"plugin-manifest","format":"markdown","content":"# rust_api Documentation\n\nPath: `plugins/rust_api/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/README.md)","tokens":300,"sizeBytes":175},{"name":"README.md","path":"plugins/tray/README.md","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/tray/README.md","title":"tray Documentation","category":"plugin-manifest","format":"markdown","content":"# tray Documentation\n\nPath: `plugins/tray/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/tray/README.md)","tokens":300,"sizeBytes":163},{"name":"README.md","path":"plugins/window/README.md","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/window/README.md","title":"window Documentation","category":"plugin-manifest","format":"markdown","content":"# window Documentation\n\nPath: `plugins/window/README.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/window/README.md)","tokens":300,"sizeBytes":169},{"name":"config.toml","path":".codex/config.toml","rawUrl":"https://raw.githubusercontent.com/chen08209/FlClash/HEAD/.codex/config.toml","title":"Codex Directive: config.toml","category":"codex-rule","format":"markdown","content":"[[hooks.PostToolUse]]\nmatcher = \"apply_patch|Bash\"\n\n[[hooks.PostToolUse.hooks]]\ntype = \"command\"\ncommand = \"bash tool/hooks/comment_density.sh\"\ntimeout = 15\nstatusMessage = \"Checking comment density\"\n","isInternal":false,"tokens":51,"sizeBytes":200}],"systemPromptSnippet":"<yakaai_skills repo=\"chen08209/FlClash\">\n<!-- File: .agents/rules.md (Tokens: ~11631 | Category: root-instruction) -->\n# Rules\n\nThese are repository coding and testing conventions. Codex command permission rules belong in `.codex/rules/*.rules`; see `.agents/agent-config.md` before adding those.\n\n## Dart and Flutter Style\n\nThe lint set lives in `lint_options.yaml` at the repo root. The root `analysis_options.yaml` and every local plugin under\n`plugins/*` include it, so application and plugin code are held to the same rules. Add or change a rule there, not in an\nindividual `analysis_options.yaml`; those files carry only their own `analyzer.exclude` entries.\n\n`lint_options.yaml` enforces these non-default rules:\n\n- `prefer_single_quotes: true`: always use single quotes.\n- `require_trailing_commas: true`: use trailing commas in multi-line argument lists.\n- `sort_child_properties_last: true`: `child:` must be the last named parameter.\n- `avoid_print: true`: do not use `print()` calls.\n- `prefer_const_constructors: true` and `prefer_const_declarations: true`.\n- `prefer_final_locals: true` and `prefer_final_in_for_each: true`.\n- `always_declare_return_types: true`.\n- `only_throw_errors: true`: throw an `Exception` or `Error`, never a bare `String`.\n\nFailures whose whole content is a message meant for the user throw\n`MessageException` from `lib/common/exception.dart`. Its `toString()` is the bare\nmessage, which is what `globalState.safeRun` surfaces in the dialog, and the\nthrow stays catchable as an `Exception` and carries a stack trace. Assert on it\nwith `isA<MessageException>().having((e) => e.message, 'message', ...)`, not on a\nraw string.\n\n### Corner Radius\n\nRounded corners are superellipses (continuous corners) everywhere, never circular arcs; when a component offers\nboth, pick the superellipse. `test/lint/superellipse_corners_test.dart` fails on the circular APIs below in `lib/`. Use\nthe superellipse API at each layer:\n\n- Shapes: `RoundedSuperellipseBorder` instead of `RoundedRectangleBorder`.\n- Clips: `ClipRSuperellipse` instead of `ClipRRect`.\n- Container decorations: `ShapeDecoration(shape: RoundedSuperellipseBorder(...))` instead of\n  `BoxDecoration(borderRadius: ...)`; borders move to the shape's `side`, and a `Container` with\n  `clipBehavior` still clips to the shape path.\n- Canvas: `canvas.drawRSuperellipse(RSuperellipse.fromRectAndRadius(...))` instead of `drawRRect`.\n- Ink: `InkWell.customBorder` with an `AppShape` instead of `InkWell.borderRadius`.\n\nPassing `BorderRadius.circular(x)` as the `borderRadius` argument of these APIs is expected — it only\ncarries the corner magnitude; the rendered geometry stays a superellipse.\n\nPills are no exception. `StadiumBorder` joins its round caps to the straight sides with a curvature jump, while\n`AppShape.full`, a superellipse at full radius, blends them, and on a square it renders the exact circle, so it serves\ncircles too. Material 3 defaults every button, icon button, segmented button, and search bar to `StadiumBorder`;\n`ThemeData.withAppShapes` replaces that for the button themes, and a local `FilledButtonTheme` or `IconButtonTheme`\nreplaces the app's, so merge the ambient `XButtonTheme.of(context).style` into it, as `CommonMinFilledButtonTheme`\ndoes. `CircleBorder` and `BoxShape.circle` are fine for something that is always square.\n\nAPIs that accept only a `Radius`, such as `ScrollbarThemeData.radius`, keep circular corners; supply the superellipse\nwith an enclosing clip or shape where it shows. Framework widgets that paint their own circular corners with no shape or\ndecoration to override are left as Material draws them; switches are `CommonSwitch`, drawn by the app. Tooltips take\ntheirs from `withAppShapes`, which sets `tooltipTheme.decoration`.\n\n### Typography\n\nText sizes follow Apple's iOS Dynamic Type scale at its default size, on every platform. `AppTextStyle` in\n`lib/ui/typography.dart` holds its eleven styles, `largeTitle` to `caption2`, with Apple's leading and weights, plus two\nsteps the scale lacks: `display` for a dashboard figure and `pageTitle` for an app bar. Take one with\n`AppTextStyle.body.of(context)`, pass `emphasized: true` for the scale's emphasized weight (bold for the titles,\nsemibold for the rest), and change color or family with `copyWith`; a weight is either regular or emphasized. Text\nsized to the shape it marks, such as a letter scaled to its icon, comes from `AppTextStyle.custom`. The dashboard's\nroles (`DashboardText` in `lib/views/dashboard/style.dart`) are built on the same types. `ThemeData.withAppTypography`\nmaps Material's `TextTheme` slots onto the scale so Material components follow it; app code never reads those slots.\n\nApp bar titles sit at the leading edge in `pageTitle` on a root page and on any desktop page, at the leading edge in\n`headline` on a pushed page in the mobile view, and in the bar's centre in `headline` on a bottom sheet. A title widget,\nsuch as the editor's name field, stays at the leading edge. Past a back button a title keeps the bar's action inset\nclear of the button on either side.\n\n`test/lint/typography_test.dart` fails on `textTheme` anywhere in `lib/` but `typography.dart`, and on `fontSize:` or\n`fontWeight:` outside `lib/ui/`, except in the code editor, whose text follows the editor font-size setting.\n\n### Text Field Suggestions\n\nOn Android, Flutter maps `enableSuggestions: false` to the visible-password input type, not only to \"no suggestions\"\n(`TextInputPlugin.inputTypeFromTextInputType` in the engine). Input methods then treat the field as a password: no\nclipboard panel, no Chinese layouts, and on some systems a secure keyboard. With the app's clipboard permission denied,\nthat leaves no way to paste a link at all. Only the secret fields, `SecretTextFormField` and `FormRowSecretTextField`,\nask for a password keyboard; a link, key, rule or regex field that should not be corrected sets `autocorrect: false`\nalone. `test/lint/text_field_suggestions_test.dart` fails on `enableSuggestions: false` or\n`TextInputType.visiblePassword` anywhere else in `lib/`.\n\n### Length Units\n\nFlutter lengths are logical pixels, the unit Android calls dp. Comments, test names, docs and reports give them in dp,\nas in `48 dp`, never px: a reader takes px for physical pixels and misjudges the size by the device pixel ratio. Write\n\"physical pixel\" in full where the code really works in them, as the hairline snapping in\n`lib/ui/list_row_separator.dart` and the Win32 frame metrics in `plugins/window` do; raster image sizes in\n`tool/` and CSS under `site/` keep px. `test/lint/length_unit_test.dart` fails on a number followed by px in `lib/`\nand `test/`.\n\nCI gates formatting: `dart format --output=none --set-exit-if-changed lib test\ntool plugins setup.dart` runs before `flutter analyze`.\n\nThese directories are excluded from analysis:\n\n- `build/**`\n- `lib/l10n/intl/**`\n- `lib/**/generated/**`\n- `plugins/**`\n- the platform runner directories: `android/`, `ios/`, `web/`, `windows/`, `macos/`, `linux/`\n\n## Comments\n\nComments are part of the code and some of them are load-bearing. What this repository limits is the useless and the\nfrequent. Every comment that restates the code devalues the ones carrying real information, until readers skim past all\nof them — a file with three comments that matter is more readable than one with thirty.\n\nThe two halves are enforced differently, because only one of them can be counted. Whether a comment is useless is a\njudgment, and it lives in the rules below and in review. Whether comments are too frequent is arithmetic, and\n`comment-density` measures it.\n\n### What a Comment Must Earn\n\n- Write one when it carries something the code cannot: a non-obvious constraint, an upstream or platform behavior being\n  worked around, a reason the next reader would otherwise get wrong, a coupling to a value defined somewhere else.\n- Never restate what the code already says. If the comment and the line below it convey the same thing, the comment is\n  noise.\n- Never narrate. Not what this diff changed, not what the code used to be, not the order you did things in. Version\n  history belongs in git and change rationale belongs in the commit message.\n- Never annotate line by line or statement by statement. A block that seems to need a comment per step needs better\n  names or a smaller decomposition instead — reach for structure before prose.\n- Prefer encoding the intent in structure and naming. A named mixin, type, or method that makes the invariant hard to\n  break beats a paragraph asking the next reader not to break it.\n- Delete commented-out code, stale version notes, and comments that only restate the code, whenever you edit the file\n  that contains them. This does not need approval.\n- These are not comments and are exempt from every rule here, including the density count: analyzer and linter\n  directives (`// ignore:`, `// ignore_for_file:`, `// coverage:ignore`), license and copyright headers,\n  code-generation markers, and comments inside vendored upstream code such as `lib/ui/open_container.dart`.\n\n### Density\n\n`tool/hooks/comment_density.sh` fails a file whose added lines are more than 5% standalone comment lines, ignoring\ndiffs under 20 added lines so small edits are never caught. The number is calibrated on this repository's own history:\nhealthy changes sit at or under 3.6%, while the core fix that prompted the gate ran 22.4%.\n\nIt is a ceiling on frequency, not a target to fill. Do not read `core/`'s inherited mihomo density as a quota either —\nit is forked upstream code, not a house style.\n\nThree gates run the same script, and they do not have equal force. The `PostToolUse` hooks in `.claude/settings.json`\nand `.codex/config.toml` run after the tool and hand offending lines back as feedback; neither can undo the completed\nwrite. The `comment-density` pre-commit hook fails the commit, and it is the only hard gate that covers every tool.\nBehavior is pinned by `tool/hooks/comment_density_test.sh`, which CI runs through `tool/hooks/test.sh`.\n\nWhen a change genuinely warrants more, raise the ceiling for that run rather than working around it:\n`COMMENT_DENSITY_MAX=20 git commit`, or `SKIP=comment-density git commit` to step past it entirely.\n\n### Where Knowledge Belongs\n\nA comment is one of three destinations, and often the weakest. Pick by where the constraint would be violated, not by\nhow important it feels.\n\n- **Assertable behavior goes in a test.** A test is the only form that cannot drift, because it fails when the behavior\n  it describes is broken. Prefer it over both a comment and a document whenever the fact can be checked in code.\n- **Repository-wide defaults, ownership, and invariants go in `.agents/*.md` or a `.agents/skills/*/SKILL.md`.** They\n  are violated from many files, so they must reach every future agent at session start. A comment in one file cannot do\n  that.\n- **A fact that is true only at one call site, and is not visible from that call site, is the one thing a comment does\n  better than a test or a document.** Its value is being in the reader's line of sight at the moment of the edit.\n  `lib/common/constant.dart` carries one: the delay-test concurrency cap is bound to `delayTestConcurrency` in\n  `core/delay.go`, and whoever changes that number must see the constraint on the same screen. This is the case a\n  comment is for; the bar is that no test and no `.agents/` entry could hold the fact instead.\n\nBoth failure directions are real. Moving a local constraint into `.agents/` hides it from the person editing the line;\nleaving a repo-wide policy as a comment reaches only the reader of that one file.\n\n## Core API Safety\n\n- `core/Clash.Meta` is a fork of mihomo, and changes to it are budgeted for features, not repairs. Fixing a bug there is\n  low priority even when the bug is real and the fix is small: every patch is one more thing to carry across an upstream\n  rebase. Solve it on the FlClash side of the boundary and note the mihomo behaviour you are working around. Reach into\n  the submodule only for a feature that has nowhere else to live, or when the problem is one the FlClash patches\n  themselves introduced — and say which of the two it is in the commit message.\n- Do not expose direct filesystem deletion APIs through Core or helper IPC; use\n  a scope-specific cleanup API instead.\n- The desktop IPC socket in `plugins/rust_api` admits a Unix peer only when its effective uid is the app's own or root:\n  the socket lives in `/tmp`, and the Core connects as root whenever it runs setuid for TUN or under the Linux Helper.\n  A launch mode that runs the Core as any other user has to widen `admit` in `ipc/access.rs`, and Windows\n  keeps its identity check on the Dart side, which compares the named-pipe peer PID with the Core it launched.\n- Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, the\n  gomobile binding, and desktop IPC. Do not double-encode `arguments`, `result`, or event batches. A response whose\n  `file` is set was spilled to disk; a transport resolves it with `resolveSpilledResponse` before reading `result`.\n- `core/message.go` carries three event queues, and the split is load-bearing: state (loaded, geo-update,\n  route-changed), priority (every other event), and bulk (log, request, DNS). Priority and bulk evict their own oldest\n  entry under backpressure, except DNS, which enters the bulk queue through `enqueueState` so a lookup burst cannot push\n  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\n  `UpdatingAction` sweeps it as stale minutes later. Do not merge the tiers or give state eviction semantics. `enqueueState` drops silently on a full\n  queue and must stay that way: reaching it means the host stopped reading, which `logDeliveryError` already reports,\n  and reporting it from the message layer feeds the same batcher.\n- Every method of the callback interfaces in `core/mobile` (`TunHandler`, `ResultHandler`) returns an `error`. gobind\n  clears a Java exception and hands it to Go only for a method that declares one; otherwise the exception stays pending\n  and the next JNI call on that thread aborts the process, so a throw in `protect`/`resolveUid`/`resolvePackage`/\n  `onResult` would crash unrelated code. `core/mobile` turns the error into the answer the system gives when it will not\n  help: `false`, `-1`, or an empty package name.\n- `TunHandler`'s hooks in `core/lib.go` check `callbacks` under `th.mu` before every call. `clear` drops the callbacks\n  while connections may still be resolving, and a call through the nil interface panics on a mihomo goroutine, outside\n  every recovery.\n- The Android bridge resolves an owner in two steps — `ResolveUid` then `ResolvePackage` — because mihomo fills\n  `metadata.Uid` from a procfs lookup that Android Q closed off. Collapsing them back into one call that returns only a\n  package name is what left every connection reporting uid 0, so `UID` rules matched nothing.\n- The desktop delivery path in `core/server.go` must not report failures through `logError`. A log event is published to\n  the log subscriber, batched, and handed back to `send`, so a send failure reported that way feeds itself; use\n  `logDeliveryError`, which writes to stderr and latches until a frame gets through or the next connection is installed.\n  A write that fails without putting a byte on the wire — host backpressure hitting `ipcWriteTimeout`, or a payload above\n  `maxIPCFrameSize` — drops that one frame and keeps the connection: the stream is still framed correctly, and tearing it\n  down here ends the read loop, and with it the Core process. A half-written frame whose write merely timed out is\n  resumed for as long as the stall lasts: Windows Modern Standby suspends the app while the Helper's Core keeps running,\n  so the host can stop draining for hours and still come back, and go-winio reports the expiry as its own `ErrTimeout`\n  rather than `os.ErrDeadlineExceeded`, which is why `send` checks `Timeout()`. The wait has no cap on purpose, and\n  `send` holds `writeMu` throughout, so every other frame — method responses and the single batcher goroutine behind\n  the event queues — waits behind the stalled one. Memory is bounded by the queues; what gives is delivery: the state\n  queue fills and `enqueueState` starts dropping, which is the case the `UpdatingAction` sweep above exists for. Nothing\n  on the Dart side restarts the Core over a stall: `CoreRpcClient` times each pending request out on its own and hands\n  the caller `null` (a `no_response` exception for message methods), and the sweep clears core-scope updating state\n  minutes later. Only a half-written frame that fails outright desynchronizes the stream, and that is the one case\n  `send` closes on.\n- Core method handlers are plain functions that return their answer, registered in the `methodHandlers` table in\n  `core/method.go` through the adapter matching their shape. `dispatchMethodCall` already runs every call on its own\n  goroutine and `handleMethodCall` turns a panic into an `internal_error` answer, so a handler blocks for as long as its\n  work takes and never spawns a goroutine just to answer. Work that outlives the answer goes through `safeGo`, which\n  recovers; a bare `go` puts a panic outside every recovery and kills the process, which on Android is the whole\n  application. The exported Android entry points in `core/lib.go` do not reach `handleMethodCall`, so each one carries\n  its own recovery.\n- A handler fails by returning an `error`: a `*MethodError` travels as it is and anything else under `core_error`. The\n  exception is `withMessage`, for the methods whose result the host reads as a message that is empty on success\n  (`_invokeMessage` in `lib/core/interface.dart`): there a plain error becomes that message and only a `*MethodError`\n  fails the call. Moving a method between the two changes what Dart receives.\n- `dialer.DefaultSocketHook` and `process.DefaultPackageNameResolver` are installed exactly once, by `installHooks` in\n  `core/lib.go`, and never cleared. mihomo checks `DefaultSocketHook` for nil once and dereferences it again when the\n  socket is created (`component/dialer/socket_hook.go`), so clearing it while a dial is in flight calls a nil func\n  value. Stopping the TUN swaps `activeTunHandler` instead.\n- `tunnel.AllProxies()` returns a shared, cached map — never modify it. The cache is invalidated by\n  `invalidateAllProxies` on `tunnel.UpdateProxies` and validated against each provider's `Version()`, so a rebuild\n  costs one read per provider rather than one per proxy. Anything else added to `tunnel/patch.go` that derives from the\n  proxy set needs both signals: the external controller can reload the config through `hub/route/configs.go` without\n  going through FlClash's `applyConfig`, so a hook on the FlClash side alone would miss a profile switch.\n- Core state that mirrors mihomo state goes stale at the next `applyConfig`, which replaces every proxy, provider and\n  rule. Read the tunnel instead of caching a snapshot of it: `lookupExternalProvider` kept one that was rebuilt only\n  when the host asked for the provider list, and the host asks after a successful setup and not after a failed one, so\n  an update ran against a provider the tunnel no longer held — downloading, writing to disk and reporting success\n  against nothing.\n- Selection writes take `selectMu`, not `configMu`. mihomo's `Selector.Set` has no lock of its own, so the writes need\n  mutual exclusion against each other and against `patchSelectGroup` — but not against a whole config apply, which is\n  what `configMu` made a proxy switch wait for, provider downloads included. `patchSelectGroup` takes `selectMu` under\n  `configMu`, fixing the order as `configMu` → `selectMu`.\n- Queueing for the delay-test semaphore and probing the node each get the full timeout in `handleTestDelay`. With one\n  shared deadline, a node that waited behind a saturated semaphore reached `URLTest` with almost nothing left, so a bulk\n  test of a large subscription reported whatever sat at the back of the queue as unreachable.\n- A delay test that the Core does not answer is a fault of the Core or the channel, never a verdict on the proxy:\n  `handleTestDelay` returns within its two timeouts on every path. `asyncTestDelay` therefore returns null instead of a\n  `-1` delay, and `ProxiesAction` leaves the last measurement in place and abandons the rest of the run. Writing a\n  timeout there is what made a reachable node read as unreachable whenever the host deadline beat the Core's.\n- Delay-test progress lives in `pendingDelayTestsProvider`, not as a sentinel value in `DelayDataSource`. A delay of 0\n  used to mean \"testing\", which let a result and the state of a test overwrite each other and left cards spinning\n  forever when the Core restarted. The run owns its keys and releases them in a `finally`, so nothing depends on a\n  reply arriving; core status leaving `connected` cancels every run in flight.\n- Some constants in `lib/common/constant.dart` mirror the Core and move with it. `delayTimedOutValue` is the `-1` that\n  `core/delay.go` reports for a delay test that ran out of time; any other negative value failed outright.\n  `downloadTimeoutDuration` matches mihomo's `resource.DefaultHttpTimeout`, which bounds the Core's provider downloads\n  end to end. `shareLinkSchemes` are the schemes mihomo's `ConvertsV2Ray` reads, less http(s), which is a subscription.\n- Anything on the mihomo side that is reached from both a user-triggered core method and mihomo's own background\n  scheduler needs its in-flight guard on the FlClash side. `updater.UpdateMMDB` and its siblings have none — only the\n  batch `UpdateGeoDatabases` does — and two concurrent runs close the mmap'd database twice, so `handleUpdateGeoData`\n  claims per resource and `updater.GeoUpdateHook` releases.\n- A failed `applyConfig` rolls the tunnel back to the default config, and that rollback is the whole recovery:\n  `handleSetupConfig` returns the error and stops there. Do not add a teardown on top of it — stopping the listeners\n  takes the app offline over a profile the user can still switch away from, and the error already reaches the host,\n  which is what surfaces the failure (`MessageException` on the Flutter side, the config-error toast on Android). Keep\n  an empty `config.yaml` out of the failure path — it is how the app says \"no profile selected\", and `loadConfig`\n  resolves it to the defaults.\n- Package `init` in the Android library runs while the `.so` is being loaded, so a panic there takes the application\n  down before it can report anything. `platform/limit.go` arms an fd-pressure probe and degrades to never blocking when\n  it cannot; keep that shape for anything else `init` sets up that correctness does not depend on.\n- No file in `core/` uses cgo: the only C in the Android library is the glue gobind generates during `gomobile bind`.\n  Android-only files take `//go:build android` and desktop-only ones `!android`, so `GOOS=android CGO_ENABLED=0 go vet`\n  checks the Android side without an NDK. Keep it that way; cgo in `core/` puts the NDK back in front of that vet and of\n  `go build ./...` on every developer machine.\n\n## Lifecycle Rules\n\n- Crash recovery is owned by `BootGuard` (`lib/common/boot_guard.dart`), and the signal it acts on is the persisted\n  `BootRecord`, never a crash reporter. The record is stamped `starting` before `startCore` and `running` once\n  `_initApp` finishes, so only a launch that died before reaching `running` is a failed launch — a crash after hours of\n  runtime, or a process the system reclaimed in the background, is not one. `ApplicationExitInfo` can only veto a\n  failure (user stop, low memory, signal, package change), never create one, and its timestamp is consumed through\n  `handledExitAt` so the same exit cannot be counted twice.\n- `BootGuard` is an Android-only mechanism and gates itself: on every other platform `evaluate`, `markRunning` and\n  `markClosed` return without touching preferences. The desktop has no `ApplicationExitInfo` equivalent to attribute an\n  exit, so a bare sentinel there would read a window closed on the disclaimer dialog, or a session ended by shutdown,\n  as a failed launch. Keep the platform check inside the guard; callers in `bootstrap.dart` and `SystemAction` stay\n  unconditional.\n- Recovery is graded: the first failed launch only skips `initStatus`, and `currentProfileId` is cleared only from\n  `crashRecoveryClearThreshold` consecutive failures on. Do not let a single interrupted launch write to the config.\n- Desktop process ownership belongs to `DesktopCoreLifecycle`; do not start/kill `FlClashCore` from providers, widgets,\n  managers, or ad hoc exit callbacks. Acquire and release it through a `CoreProcessLease`.\n- `CoreController.close()` and platform `close()` implementations are terminal and idempotent. Application shutdown must\n  stay centralized in `SystemAction`/`SystemExitCoordinator`.\n- Android start/stop MethodChannel calls are optimistic UI commands. Keep latest-wins arbitration in native\n  `ServiceState`; do not add a Flutter completion callback that creates a second lifecycle owner. What flows back is\n  the revision-stamped `stopped` report, which Flutter mirrors with `setRunning(false)` and never answers with a\n  decision of its own.\n- Android `RunState` is derived in `ServiceStateMachine.publish`; do not assign it from a transition step, and do not\n  add a second \"is it running\" field beside `ServiceController.runtime`. Every divergence between the tile and the\n  service so far came from a copy that one path forgot to update.\n- Android service callbacks are not automatically user intent. Route explicit Quick Settings, Always-on VPN, and revoke\n  actions through `ServiceState` and keep `ServiceController` as the sole binding/run-time owner.\n- Every `BroadcastReceiver.goAsync()` path must finish its `PendingResult` exactly once. A watchdog may release the\n  broadcast lease, but must not cancel, reverse, or otherwise redefine the service operation.\n- Anything that probes through the outbound route is a `RoutedProbe` judged against `routeTrackerProvider` (Route\n  Consistency in `.agents/architecture.md`); the Core stamps every answer with its counters and hits and checks the\n  hits again on a move, so do not add a change signal, a trigger counter, or a `coreStatusProvider` watch of your\n  own.\n- Presentation smoothing such as `CoreStatusButton`'s connecting hold must remain local display state. It must not delay or\n  overwrite `coreStatusProvider`, and a real failure must bypass/cancel the hold immediately.\n- Safe mode (`SAFE_MODE` define, see the safe mode section of `.agents/architecture.md`) is consulted by each host\n  integration at its owner, never by rewriting the user's settings. `safeModeBuild` is for handles created before the\n  container exists; everything else reads `safeModeProvider` so a test can override it.\n- `Tray.hide()` is idempotent on all three desktop platforms and returns native state to \"`show` was never called\".\n  `AppTray.shutdown()` latches, so no later `update()`/`updateTitle()` can resurrect the icon once shutdown begins.\n  Keep it that way; a resurrected icon outlives `exit(0)` as a Windows ghost icon, because `setPreventClose(true)`\n  means `WM_DESTROY` never runs.\n- The `tray` plugin owns call ordering, idempotency, serialization, and unchanged-payload suppression. Application code\n  declares desired state through one `Tray.show(TraySpec)` call and must not add platform branches to work around\n  ordering. Platform branches in `lib/common/tray.dart` are only for deliberate product differences (macOS speed title\n  and group submenus); query `Tray.instance.capabilities` for ability differences.\n- Every native `show` returns whether the tray accepted the payload, and reports `false` instead of showing a broken\n  icon. `Tray` caches the payload signature only on `true`, so a rejected `show` is retried by the next update rather\n  than suppressed until restart. Windows accepts a payload the shell refused, as at sign-in, and retries the icon\n  natively until it lands, so `Tray` still treats it as visible and `hide` reaches native to delete an add that may\n  have landed. Any test that mocks the `tray` channel must return `true` from `show`.\n- Reading the Wi-Fi SSID is opt-in work, not ambient state. `ConnectivityManager` reads it only while `excludeSSIDs` is\n  non-empty, because that list is its only consumer through `suspendProvider`, and the read costs a blocking platform\n  call plus a location permission on Android and macOS. A second consumer must widen that gate, not drop it.\n- On Android the `wifi_ssid` permission is only `granted` once `ACCESS_BACKGROUND_LOCATION` is held too (Q+). Fine\n  location alone reads the SSID in the foreground but returns `UNKNOWN_SSID` once the app is backgrounded, which is\n  where on-demand spends most of its time. `getSsid` itself only needs fine location, so the split stays inside the\n  plugin.\n- No `wifi_ssid` implementation may answer `getSsid` on the platform thread. Android resolves through a network callback\n  with a timeout, Linux through `g_task_run_in_thread`, and macOS through `ssidQueue` after checking the location\n  authorization, because CoreWLAN reaches `wifid` over XPC and a wedged daemon would freeze the window. Windows is the\n  outstanding exception: `WlanQueryInterface` still runs inline, since replying off-thread there needs a window-message\n  hop that the plugin API does not provide.\n- Android's `system` nameserver resolves only through the DNS `NetworkObserver` reports; mihomo builds it with no\n  fallback, so with no list every lookup through it fails at once. The Core tests delays and updates providers with no\n  service running, so the observer lives for the process from the first `init` or native `quickSetup` and never sends\n  an empty list on a service stop.\n- The delayed DNS re-check `NetworkObserver.onLosing` posts is deliberately left un-deduplicated. The runnable\n  re-reads `networkInfos` and does nothing when `onLost` already dropped the network, and `updateDns` calls into the\n  Core only when the resolved list or the preferred network changed, so a network that reports `onLosing` repeatedly\n  costs two comparisons per event. Holding the pending `Runnable` to `removeCallbacks` it changes no behaviour and adds\n  state that has to stay in sync with the map.\n\n## UI Isolate Work\n\nEverything the UI isolate runs synchronously shares a frame with build, layout and paint: 16 ms at 60 Hz, 8 ms at\n120 Hz. Computation may take as long as it needs anywhere else, so this rule is about where work runs, not how much of\nit there is.\n\n- Work that grows with the user's data (profiles, rules, proxies, groups, dialers, icons, connections) and can take\n  more than about 8 ms on the largest data the app expects runs in an isolate, through `compute` or `Isolate.run`.\n  Thousands of rules or proxies and hundreds of groups are in that range. `makeRealProfileTask` in\n  `lib/common/task.dart` and the backup tasks in `lib/common/backup.dart` are the existing shape.\n- Make the algorithm linear or cached first. Work superlinear in that data, such as a walk of the whole graph per\n  item, a scan of every group per group, or a list copy per inserted element, is a defect on any isolate; moving it\n  only hides the cost until the data outgrows the move.\n- Sending data to an isolate copies it on the sending isolate, so hand the task the smallest input it reads. A value\n  the UI reads synchronously, such as a sync provider watched in `build`, becomes an async provider with a loading\n  state rather than a blocking call.\n- drift runs SQL on its own isolate but maps rows into models on the caller's, so a watched query over a large table\n  costs the UI isolate on every emission, and any write to that table makes it emit again.\n- Bounded work stays where it is: a provider recompute that reuses a cache, a single-item update, a search filter over\n  a list sized for scrolling.\n- An editor document stays in the Rust rope. Open a file for `EditorPage` with `openEditorFile` rather than reading it\n  into a string, and read an edited one through `EditorContent`; a profile can be tens of megabytes.\n- Decide on measurements taken at the expected maximum. A `flutter test` probe that counts provider recomputes, times\n  the provider body and hooks `debugOnRebuildDirtyWidget` shows what reruns and how it scales; its JIT timings are not\n  device numbers, so a case near the line is settled with a profile build. No gate checks this rule.\n\n## Testing Rules\n\nThe `core/` directory is excluded from automated coverage accounting. Do not add coverage instrumentation or coverage\ncollection for code under `core/`. CI still runs `CGO_ENABLED=0 go test .` and `go vet .` to compile/check the Go wrapper,\nplus a `GOOS=android` vet that covers the `android` files the first two exclude; verify cross-language\nprotocol behavior through shared Dart contract tests under `test/core/` and native platform build checks.\n\nA Go test in `core/` that reaches `sendMessage` — directly, or through `handleStartLog` or `updater.GeoUpdateHook` —\nleaves events in the process-wide batcher, which flushes them up to `messageBatchInterval` later into whichever\nconnection `captureFrames` has installed by then. Either keep the event out of the batcher or end the test with\n`settleMessageBatcher`. That batcher runs for the whole test binary and reads `conn` under `connMu`, so install a test\nconnection with `swapConn`; a bare assignment races every event it happens to be delivering, and `go test -race` catches\nit in an unrelated test.\n\nRegister fallback values for freezed params used with `any()` matchers.\n\n`tool/check_coverage.dart` enforces a total floor passed by CI plus per-group floors declared in `_groupFloors`. Raise a\ngroup's floor when new tests lift it; do not lower one to make a run pass.\n\nEvery measured group needs a floor. A group the report measures but `_groupFloors` does not declare fails the run, so\nadding a top-level directory under `lib/` means adding its floor in the same change. Set a new floor at or just below\nthe coverage the directory actually has; the point is to stop a slide, not to backfill tests before the directory can\nland.\n\nPrefer `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))` over `CoreController.test(fake)` in new and\ntouched tests. `CoreController.test` claims the process-wide singleton, which makes a global read and a provider read\nresolve to the same fake, so it cannot fail on a call site that still reaches for the global. A test that does claim\nthe singleton calls `CoreController.resetInstance()` in `tearDown`. It also only reaches the global `coreController`\nwhen nothing has read it yet: that is a top-level `final`, bound to whichever instance existed at the first read, which\nin a shared isolate is another file's.\n\n### Shared Test Isolate\n\n`tool/test.dart` imports many test files into one entrypoint, so process-wide state outlives the file that set it, and\nevery file's `main()` runs before any test of the entrypoint does. Keep `main()` to declarations and\n`TestWidgetsFlutterBinding.ensureInitialized()`: a mock installed there is replaced by the next file's before a test\nsees it. Set up what the tests need in `setUp` or `setUpAll` instead, from the helpers that rebind the singletons:\n\n- `appPath` resolves its directories once per process. Point them at a test's directory with `await useAppPaths(dir)`\n  from `test/helpers/app_paths.dart`, not with a `PathProviderPlatform` fake, which only works while nothing has asked\n  `appPath` yet.\n- `preferences` opens its store once per process. Use `await mockPreferences(values)` from\n  `test/helpers/preferences.dart` rather than `SharedPreferences.setMockInitialValues` alone, which leaves `preferences`\n  on the store it already opened.\n\nBoth cache the resolved value, not the future that produced it. A future completed in one test's fake-async zone runs\nits listeners in that zone, so a later test that awaits it never resumes. Await the helpers outside `testWidgets`\nbodies for the same reason.\n\nA test that only passes in a fresh isolate is leaning on a global it did not set up: `database_test.dart` once caught\nits write failures only because `currentAppLocalizations` threw before anything had loaded them. The script reruns such\na file alone so it cannot fail the run, and lists it; fix it where it reads the global.\n\nConstruct the Android lib handler with `CoreLib.scoped(fakeService)`. The `service` global is gated on `Platform.isAndroid`\nand is therefore null on every test host, so a `CoreLib()` built from it silently takes the null-service fallback on every\npath. `CoreLib.scoped` binds an explicit `Service` instead; reset the singleton with `CoreLib.resetInstance()` in `tearDown`.\n\nThree globals in `lib/common` reach real host state and carry a `@visibleForTesting` seam to stand in front of it:\n`AutoLaunch.launcher`, `listNetworkInterfaces` and `LinkManager.uriLinkStream`. Replace the launcher in particular — every\n`enable`/`disable`/`isEnabled` writes the actual autostart entry (a LaunchAgents plist, a `.desktop` file or a registry\nkey), so a test that skips the seam registers the test binary on the machine that ran it. `updateStatus` returns early\nunder `kDebugMode`, which is always true beneath `flutter test`, so its remaining branches cannot be reached from a test\nat all; `test/common/launch_test.dart` pins the early return instead.\n\n`pumpAndSettle` never returns on a page holding `EditorView` (what `EditorPage` embeds): the code editor blinks its\ncaret forever, so frames keep being scheduled. Pump explicitly instead. `encodeYamlTask` and its neighbours in\n`common/task.dart` hand work to a real isolate through `compute`, which only runs outside the fake-async zone, so a test\nawaiting one needs `tester.runAsync(...)` between the pumps.\n\nThe `@visibleForTesting` `database` setter in `lib/database/database.dart` deliberately does not close the instance it\nreplaces. Tests inject `NativeDatabase.memory()`, which holds no file handle, and `Database.close()` is async while the\nsetter is not, so closing there would either be unawaited or force the seam to become async for no gain. A test that\ndoes open a file-backed database owns closing it.\n\n`system.isAndroid` / `isMacOS` / `isWindows` / `isLinux` read `dart:io` `Platform` and cannot be overridden, unlike\n`debugDefaultTargetPlatformOverride`. A branch behind one of them is only ever exercised on a host that matches it, so CI\n(`ubuntu-latest`) and a macOS working copy measure different coverage for the same test. Assert host-agnostic behavior,\nand leave headroom under a group floor that covers such a branch.\n\nA platform decision that drives layout takes `isDesktop`/`isMacOS` as parameters and reads `system` only at the call site,\nso every platform's outcome is reachable from one host. `getWindowHeaderHeight` and `showsWindowHeader` in\n`lib/common/layout.dart` own the window header rule for both `WindowHeaderContainer` and `overlayTopOffset` — they must\nagree, or the content is offset by a header that is not there. `WindowHeaderBar` takes its height and slots as arguments,\nwhich is what lets `test/manager/window_header_test.dart` measure the Windows caption bar on a macOS host.\n\nA `Stack` under loose constraints sizes to its non-positioned children, and falls back to `constraints.biggest` only when\nit has none — so a bar that fills the window must keep every slot positioned. One non-positioned child is enough to\ncollapse `WindowHeaderBar` to that child's width: the macOS title did exactly that, leaving the app name over the traffic\nlights and the raw window painted black beside it, while Windows, whose slots are all positioned, stayed correct.\n`WindowHeaderLayout` positions the header across the top for the same reason, and the macOS group asserts the bar's\nwidth, not just its height — a height-only assertion cannot see this.\n\nThe same seam carries the two other places a platform decision changed what was built: `AppTray` holds `isMacOS`/\n`isWindows` as state — `AppTray()` fills them from `system`, `AppTray.forPlatform` is the test seam — and `OnDemandView`\ntakes nullable `isAndroid`/`isMacOS` that fall back to the host. Every test names the platform it means, because\n`debugDefaultTargetPlatformOverride` does not move `system` and a suite that leaves it to the host asserts the macOS\nshape on a developer machine and the Linux one on CI. `WindowHeaderContainer` builds the caption buttons on every\nnon-macOS host, so a test mounting it needs `TestApp` for `AppLocalizations` and a `window` channel mock that\nanswers `isMaximized`/`isAlwaysOnTop` with a bool.\n\nAuto-dispose providers need a container-level hold before a test reads them back. `proxyGroupProvider`, `ruleProvider`,\n`itemsProvider` and friends mix in `AutoDisposeNotifierMixin`, so a `container.read` that no widget is currently watching\nrebuilds the provider from its override and silently discards whatever the code under test wrote. Add\n`container.listen(theProvider, (_, _) {})` in the harness, as `test/providers/action_test.dart` does for `scriptsProvider`.\n\nA field that constructs its own `ValueNotifier`, `TextEditingController`, `ScrollController`, `FocusNode`, `TabController`,\n`PageController`, `AnimationController` or `StreamController` must be released in the same file.\n`test/lint/disposable_field_test.dart` enforces this by scanning `lib/`, because no lint covers it: `close_sinks` only sees\nsinks, and nothing in the standard set tracks `ChangeNotifier` disposal. A field that genuinely outlives its owner goes in\nthat test's `_allowed` set with the reason, not left bare. Controllers received as widget parameters belong to the caller\nand are out of scope.\n\nAn `IconButton` whose icon is an icon needs a `tooltip`. It is the button's only accessible name — without it TalkBack and\nVoiceOver announce nothing and the desktop build shows no hover hint. `test/lint/icon_button_tooltip_test.dart` enforces\nit and skips exactly two shapes: an `icon:` holding a `Text`, which is already a visible label, and\n`views/dashboard/widgets/core_status_button.dart`, which takes its label from an enclosing `Tooltip` (a second test fails\nif that wrapper disappears). Reuse an existing string before adding one; a label that depends on state goes on the button\ninside the `ValueListenableBuilder`, not outside it, or the tooltip cannot follow the icon. A row of window buttons hidden\nbehind `system.isMacOS` is unreachable from a macOS test host, so extract it — `WindowHeaderActions` is the pattern.\n\nA tooltip needs an `Overlay` ancestor at build time, not at hover time: `RawTooltip` builds an `OverlayPortal`, so a\nbutton whose `tooltip` has nowhere to go throws \"No Overlay widget found\" and takes its whole subtree down with it.\nEverything `buildManagerStack` wraps around `MaterialApp.builder`'s child sits *above* the app Navigator and therefore\nabove the only Overlay in the tree. A manager that renders a tooltip — `WindowHeaderLayout` and its caption buttons are\nthe case that broke Windows while macOS, which renders a bare title there, stayed clean — hosts its own with\n`Overlay.wrap`, spanning the window so the tooltip is not clipped to the widget that owns it. A test only reproduces this\nby mirroring that topology: build the widget from `MaterialApp.builder`, never from `home:`.\n\nA public top-level declaration that nothing outside its own file references is dead, and no lint catches it:\n`unused_element` covers only private ones, and a barrel `export` keeps a dead file compiling and off every\n\"unused import\" report. `test/lint/dead_file_test.dart` scans `lib/` for files whose declared names — types plus the\n`final appPath = AppPath()` singletons next to them — appear nowhere else, counting generated code as a consumer (a\nriverpod notifier is reached through its generated provider) and barrels as neither. Files publishing only extensions or\ntypedefs are skipped: those are reached through the types they attach to, never by name.\n\n`CommonScaffold` runs its body under the system bar at the foot of the window, as Android's edge-to-edge guidance\nasks, and only the end of the content clears it. A three-button bar is no exception: `ThemeManager` leaves it\nAndroid's translucent scrim (`systemNavigationBarContrastEnforced`). Telling the two apart by the side back-swipe\nzones Flutter reports reserved a gesture handle again on a real phone, so no page asks which bar it has. A scroll view\nends, and anything fixed at the foot of a body stands, `context.bottomInset` above the edge: `BottomInsetScope` (the\nhome dock, a docked search) plus the system bar `MediaQuery` still carries. The home shell takes the system bar out of\n`MediaQuery` for its tabs because the dock's inset already counts it. A bare `BottomInsetScope.of` leaves the system\nbar out, so code outside `lib/ui` never reads it. The scaffold adds its docked search to the scope around its body, so a page with a `searchState` reads\n`context.bottomInset` below the scaffold, inside `body: Builder(builder: (context) => ...)`; read with the context the\nscaffold is built from, it leaves the end of the content under the search field.\n`test/lint/scaffold_bottom_inset_test.dart` enforces both by scanning `lib/`.\n\nEvery reorderable list builds its drag proxy with `commonProxyDecorator`, which tells `DecorationListItem` it is lifted:\nthe row drops its separator and rounds from its corners in the run to a card of its own as it lifts, and back as it\ndrops. A list without it lifts a middle row with square edges. `test/lint/reorder_proxy_decorator_test.dart` enforces\nthis by scanning `lib/`.\n\nA `State.dispose()` override must not await before `super.dispose()`. `StatefulElement.unmount` calls `dispose()` and then\nimmediately asserts that `super.dispose()` already ran, so an `await` defers the call past the assert and every teardown\nthrows \"`…State.dispose failed to call super.dispose.`\" in debug and profile builds. Declare the override as `void\ndispose()` and hand async teardown to `unawaited(...)`; `Future<void> dispose() async` compiles and is the shape that\ninvites the bug.\n\nUse `ProviderContainer` directly for simple Riverpod provider tests, released with `addTearDown(container.dispose)`.\nAssert against the provider's generated public API, not its implementation, re-read source defaults before asserting\nthem, and wait on provider futures or state changes rather than fixed sleeps. The generated Riverpod `update()` method\ntakes a callback:\n\n```dart\nnotifier.update((state) => newValue);\n```\n\nWhen testing freezed models with nested objects, always round-trip through `jsonEncode` and `jsonDecode`. Direct `fromJson(toJson())` fails for nested freezed types because `toJson()` stores child objects directly instead of maps.\n\nFor async widgets, put visual cleanup in `finally` when the action may throw. Focused widget tests should cover success,\nfailure, disposal, and any timer boundary that changes visible state.\n\n## Running the App Locally\n\n- An agent starts FlClash on the host only as a safe mode build (`--dart-define=SAFE_MODE=true`, see\n  `.agents/commands.md`). A normal build takes over the host network, so the user runs it, never the agent.\n- Never end a FlClash process the agent did not start, and never kill by name (`pkill`, `killall`, a pattern on the\n  executable path): the user's own instance matches too. Record the pid of every process you launch, check with\n  `ps -o pid,command -p <pid>` that its executable is your own build output, and end only that pid.\n- Only a macOS or Android debug build has its own data directory; every other build shares the installed app's data\n  directory and single-instance lock, so it cannot start while the user's instance runs. `.agents/commands.md` lists\n  which builds are kept apart.\n\n## Commit Messages\n\nSubjects follow Conventional Commits and are enforced by the `commit-msg` hook in `.pre-commit-config.yaml`, which runs\n`tool/hooks/commit_msg.sh`:\n\n```text\n<type>[(scope)][!]: <description>\n```\n\n- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`.\n- Scope is optional and lower case; use a comma to list several, as in `fix(core,android)`.\n- `!` before the colon marks a breaking change.\n- Descriptions start in lower case, omit the trailing period, and keep the whole subject within 100 characters.\n  Identifiers and acronyms keep their own casing, as in `fix(ui): AppBar text is truncated`.\n- `Merge`/`Revert` subjects and `fixup!`/`squash!` commits are exempt.\n- No `Co-authored-by` trailer crediting a coding agent, whatever that tool's own convention says. The history\n  records who owns the change, not which tool typed it; human co-authors are still fine. The hook rejects the\n  known agent identities.\n\nWrite what the change does, not that something changed: `perf(views): stop redoing per-frame work in build`, not\n`Optimize more details`.\n\nInstall the hooks once with `pre-commit install --hook-type pre-commit --hook-type pre-push --hook-type commit-msg`.\n\n### Fixup Commits\n\nA follow-up to a change that has not shipped in a stable release goes in as `git commit --fixup=<target>`, aimed at the\ncommit that introduced what it amends.\n\n- 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\n  for fixups, so do not steer away from it or flag it.\n- A commit a stable tag contains takes no fixup; land the follow-up as a regular commit. Check with\n  `git tag --contains <target> | grep -v -- '-pre'`, which must print nothing.\n- Asking for a fixup means creating the `fixup!` commit only. Do not run `git rebase --autosquash` or otherwise fold it\n  into its target unless the user asks for the squash.\n\n### Changelog Trailers\n\n`tool/changelog.dart`\n\n<!-- File: .agents/skills.md (Tokens: ~388 | Category: root-instruction) -->\n# Agent Skills\n\nRepo skills live under `.agents/skills/*/SKILL.md`, where Codex discovers them directly. Claude Code discovers them\nthrough the symlinks in `.claude/skills/`, one per skill, pointing back at the `.agents/skills/` directory. Both tools\nmatch a task against the skill `name` and `description`, then load the full instructions only when it matches.\n\n## Available Repo Skills\n\n- `localization`: hardcoded UI text scans, ARB updates, locale generation, and localization verification.\n- `ui-work`: Flutter UI, widgets, Material You styling, icons and glyphs, shapes and corner radii, navigation surfaces,\n  async feedback, and user-facing interactions.\n- `core-platform`: Core lifecycle/process ownership, Android services, Go event delivery, desktop IPC, platform managers,\n  build hooks, system proxy, tray, VPN/TUN, and the Windows and Linux Helper.\n- `pre-commit-quality-review`: lightweight triage before commits, with scoped structural review and user decisions on concrete regressions.\n\n## Authoring Notes\n\n- Add new repeatable workflows as `.agents/skills/<skill-name>/SKILL.md`, and add the matching\n  `.claude/skills/<skill-name>` symlink so Claude Code sees it too; remove both when retiring a skill.\n- Git stores the symlinks as links only where the checkout supports them. A Windows checkout needs\n  `core.symlinks=true` and Developer Mode, otherwise the links become plain files and Claude Code loads no repo\n  skill there; Codex is unaffected because it reads `.agents/skills/` directly.\n- Keep skill descriptions trigger-focused and start them with `Use when...`.\n- Keep long reference material in `.agents/*.md`; skills should link to it instead of duplicating it.\n- Put command permission rules in `.codex/rules/*.rules`, not in this file.\n\n<!-- File: AGENTS.md (Tokens: ~1335 | Category: root-instruction) -->\n# AGENTS.md\n\nThis file is the entry point for AI coding agents working in this repository. Keep it small: detailed guidance lives under\n`.agents/`, and discoverable repo skills live under `.agents/skills/*/SKILL.md`.\n\n## Start Here\n\nRead these files before making changes:\n\n- [.agents/project.md](.agents/project.md): project overview, versions, and build dependencies.\n- [.agents/commands.md](.agents/commands.md): build, development, code generation, and test commands.\n- [.agents/rules.md](.agents/rules.md): lint, testing, generated-code, and workflow rules.\n\nRead these only when the task touches their area:\n\n- [.agents/architecture.md](.agents/architecture.md): core integration, providers, database, managers, build system, and\n  local plugins.\n- [.agents/setting-changes.md](.agents/setting-changes.md): whether a changed setting is patched live, re-applies the\n  profile, or asks for an Android VPN restart.\n- [.agents/surfaces.md](.agents/surfaces.md): fill levels, selection and status colors, and the few borders that\n  remain, for any change to how a surface is colored.\n- [.agents/agent-config.md](.agents/agent-config.md): how to choose between `AGENTS.md`, `.agents`, skills, Codex config,\n  command rules, and hooks.\n- [.agents/worktrees.md](.agents/worktrees.md): worktree hygiene across Claude Code, Codex, and Gemini.\n- [.agents/flutter-upgrade.md](.agents/flutter-upgrade.md): SDK-pinned workarounds to recheck before changing the\n  Flutter version.\n- [.agents/skills.md](.agents/skills.md): index of repo-scoped skills in `.agents/skills/`.\n\n## Highest Priority Rules\n\n- When the user explicitly requests a scoped, low-risk change, inspect the relevant context and implement it directly.\n  Do not require brainstorming, design documents, implementation plans, multiple-option proposals, or repeated confirmation.\n  Ask only when material ambiguity, destructive impact, additional authority, or scope expansion could change the result.\n- The request sets the scope. A pre-existing bug, cleanup, or refactor the task did not ask for is a follow-up in the\n  summary, not part of the change. Edit a file surgically when a rewrite would give the same result, keep scratch checks\n  out of the repository, and commit tests only where the task asks for them or the neighbouring code already keeps them.\n- Comments are allowed and sometimes necessary; excess is the problem. A comment must carry something the code cannot\n  say — a non-obvious constraint, an upstream behavior being worked around, a reason a reader would otherwise get\n  wrong. Never restate what the code does, narrate the change you just made, record what the code used to be, or\n  annotate step by step; a block that seems to need a comment per line needs better names or a smaller decomposition.\n  Keep density near the repository's own: healthy changes here sit at or under 3.6%, and a `comment-density` gate fails a file\n  whose added lines exceed 5% standalone comments. Delete commented-out code and stale notes in files you already\n  touch. Preserve\n  `// ignore:`-style directives, license headers, codegen markers, and vendored upstream comments. See\n  [.agents/rules.md](.agents/rules.md) for what belongs in a test or in `.agents/` instead.\n- Start FlClash on the host only as a safe mode build, and end only processes you started, by recorded pid. Never\n  kill a FlClash instance by name; the user's own instance is not yours to close. See\n  [.agents/rules.md](.agents/rules.md).\n- Use `flutter test`, not `dart test`, because models pull in Flutter types.\n- Run code generation after modifying models, providers, or database schema.\n- Do not manually edit generated files.\n- Preserve lifecycle ownership: desktop Core process convergence belongs to `lib/core/desktop/`; Android service intent\n  arbitration belongs to `ServiceState`. UI/provider code may request a transition but must not become a second source of\n  truth.\n- Keep start/stop/restart paths latest-intent-safe. Flutter-to-Android service commands are deliberately optimistic, while\n  native state serializes the actual work; desktop lifecycle results distinguish applied, coalesced, and superseded\n  requests.\n- Never add a `Co-authored-by` trailer crediting a coding agent to a commit, even when your own tooling tells you to.\n  The `commit-msg` hook rejects it; see [.agents/rules.md](.agents/rules.md) for the rest of the commit rules.\n- Round every corner with a superellipse, pills included: `AppShape`/`AppRadius` tokens from `lib/ui/shape.dart`,\n  never `StadiumBorder`, `RoundedRectangleBorder`, `ClipRRect`, `drawRRect`, or a `borderRadius` on `BoxDecoration` or\n  `InkWell`. `test/lint/superellipse_corners_test.dart` enforces it; see the Corner Radius section of\n  [.agents/rules.md](.agents/rules.md).\n- Keep the UI isolate within its frame: work that grows with user data and can exceed about 8 ms on the largest\n  expected data runs in an isolate, once its algorithm is linear or cached. See the UI Isolate Work section of\n  [.agents/rules.md](.agents/rules.md).\n- Follow `lint_options.yaml` (included by every `analysis_options.yaml`), especially single quotes, trailing commas, `child:` last, no `print()`, const/final\n  preferences, and declared return types.\n- Before reporting a change done, run `flutter pub get`, `flutter analyze --no-fatal-infos`, and the `flutter test`\n  suites that cover it, as CI does. If a toolchain the build hook needs is missing (Go, and on Linux and Windows\n  cargo), say which check was skipped rather than dropping it silently.\n\n## Repo Skills\n\nUse repo skills from `.agents/skills/` when a task matches their descriptions; `.claude/skills/` symlinks expose the\nsame skills to Claude Code. Current skills cover localization, UI work, core/platform changes, and pre-commit\nstructural quality review.\n\n<!-- File: .agents/architecture.md (Tokens: ~11346 | Category: project-spec) -->\n# Architecture\n\n## Core Integration\n\nThe Go proxy core in `core/` operates in two modes.\n\nAndroid lib mode:\n\n- `gomobile bind` builds `core/mobile` into an AAR: a native library per ABI, shipped as `libclash.so`, and the Java\n  classes gobind generates in `com.follow.clash.core.mobile`. The `:core` Gradle module builds it and takes in its\n  classes and libraries (see Android Core Build).\n- The Android `:core` module owns the binding: `Core.kt` is the only caller of the generated `Mobile` class. Flutter\n  crosses MethodChannels through `lib/plugins/service.dart` and Android's `ServicePlugin` rather than calling the Core\n  directly: `${packageName}/service` for lifecycle commands and Core events, `${packageName}/core` for Core method\n  calls.\n- Flutter runs the UI isolate on Android's main thread (3.47 merges the two and ignores the opt-out in release), so\n  main-thread work anywhere in the process, the Core's included, costs frames. Only sending to Dart needs that thread;\n  a `MethodChannel.Result` may be answered from any thread. `${packageName}/core` is handled on a serial background\n  task queue and answered from the Core's goroutine, an event batch is encoded on the Core's thread and only sent on\n  the main one, `ForceGC` (called from `onLowMemory`/`onTrimMemory`) collects on its own goroutine, and\n  `NetworkObserver` refreshes DNS on its own looper.\n- `lib/core/lib.dart` (`CoreLib`) implements the shared Core interface, gates method calls on its connection completer,\n  initializes and synchronizes Android shared state, and closes the native service path exactly once.\n- Because Core is in the application process on Android, application RSS already includes Core memory.\n\nDesktop core mode:\n\n- Go core runs as a separate process with `CGO_ENABLED=0`.\n- `rust_api`'s `ipc` module provides the native local-IPC primitives: a Unix domain socket on macOS/Linux and a named pipe\n  on Windows, served by an `IpcServer` handle. Dart owns the transport state, RPC correlation, process ownership, and\n  lifecycle convergence above those primitives.\n- `lib/core/service.dart` (`CoreService`) is the composition root. It wires the IPC transport, launcher selection,\n  lifecycle controller, RPC client, and crash-event bridge, and holds no lifecycle logic of its own.\n- `lib/core/desktop/transport.dart` binds an `IpcServer` and turns its events into ready, connected, disconnected, and\n  failed transport events plus a frame stream. A connection that ends on an I/O error is a disconnect carrying that\n  error; failed means the server itself is gone and is terminal for that transport. A replaceable binding keeps RPC\n  subscriptions stable when a failed or stale transport must be rebuilt.\n- `lib/core/desktop/rpc_client.dart` owns request IDs and pending completers, waits up to 10 seconds for a connection,\n  applies a three-minute default method timeout, unwraps `CoreMethodResponse`, and fails all pending calls when transport\n  disconnects or closes.\n- `lib/core/desktop/lifecycle.dart` serializes process intents and owns the authoritative desktop state machine.\n- `lib/core/desktop/launcher.dart` abstracts direct child-process and privileged Helper ownership through idempotent process\n  leases. `lib/core/desktop/helper_client.dart` is the typed local HTTP client for the privileged Helper.\n\n`lib/core/controller.dart` (`CoreController`) selects the implementation based on platform. `lib/core/interface.dart` defines the shared `CoreHandlerInterface`.\n\nKey Go core files:\n\n- `core/protocol.go`: method names, event types, and the parameter and result shapes shared with Dart.\n- `core/method.go`: MethodChannel-style method-call dispatch, the handler table, and response envelopes.\n- `core/lifecycle.go`, `config.go`, `proxy.go`, `delay.go`, `provider.go`, `geo.go`, `connection.go`, `log.go`: the\n  handler functions, one file per area. `core/hooks.go` wires mihomo's hooks to them.\n- `core/message.go`: non-blocking state/priority/bulk event queues and bounded message batching.\n- `core/lib.go`: the Android library API: TUN callbacks, the event listener, and the entry points `core/mobile` binds.\n- `core/mobile/`: the package gomobile binds. gobind generates Java for its exported API, so it speaks only `int32`,\n  `bool`, `string`, `[]byte` and its own interfaces; a Go `int` would become a Java `long`.\n- `core/cmd/core/`: the desktop entry point. `core/` itself is the library package `core`, because gomobile cannot bind\n  a `main` package.\n- `core/server.go`: desktop socket/named-pipe client and framed message forwarding.\n\n## DNS, NTP and Sniffer Overrides\n\nA URL or file profile brings its own sections; a custom profile brings none. The app's DNS page\n(`PatchClashConfig.dns` with `dnsOverrideKeys`) is written over the DNS of every URL and file profile, and offers only\n`DnsOverrideKey.normalProfileKeys`: keys that name nothing of a profile's own, because a subscription's groups and rule\nsets change with every update. A custom profile keeps all three sections in `Profile.overrides`, a `ProfileOverrides`\nstored as JSON in the `profiles.overrides` column, with every key available, and NTP and sniffer exist nowhere else.\n`setupState` resolves `SetupState.overrides` by profile type, intersecting the app-wide keys with the normal set, and\n`_makeRealProfileTask` merges whatever it is handed; there is no on/off toggle, a picked key is the override.\n\nEach `*OverrideKey` names one Clash key, with `parent.field` paths for nested maps, and covers every yaml key of the\nmatching mihomo raw struct; `test/models/{dns,ntp,sniffer}_override_test.dart` read `core/Clash.Meta/config/config.go`\nand fail when a core bump adds or drops one. `overrideJson` builds the fragment in model order and skips a key whose\nvalue is empty, so a key left without a value writes nothing: the core refuses an empty `nameserver` outright.\n`mergeDnsOverride` merges one level deep so a partial fallback filter keeps the profile's other filter keys; NTP spreads\nover the profile's flat `ntp` map. For a profile whose `dns.enable` is not true, the task first fills `baselineDns` for\n`baselineDnsOverrideKeys` (`enable`, `enhanced-mode: fake-ip`, `nameserver`), the one thing the core is never without:\nwith DNS off, mihomo answers every TUN-hijacked query with an error. The baseline stays apart from the model defaults,\nwhich carry no preset of the app.\n\nAn added key starts from the spec's `defaults` through its `OverrideField.reset`, and those defaults are mihomo's: a key\nstarts at the value mihomo parses without it, presets included, such as the nameservers, fake-IP range and filter, and\nNTP server of `DefaultRawConfig`. A key mihomo leaves unset, such as `listen` or a sniff protocol's ports, starts empty\nand writes nothing until the user fills it in, and so does the fallback filter's GeoIP code, whose value is left for the\nuser to choose. mihomo sniffs a protocol listed without ports on that protocol's standard port, which the page shows as\nthe default. The tests above pin the defaults against `DefaultRawConfig`. A spec that mixes in `OverrideQuickEdit` gets\nquick edit, which opens `overrideYaml` in an `EditorPage` and rebuilds the key set from the document through\n`applyOverrideYaml`, rejecting keys outside the section and values the model cannot hold.\n\nmihomo upper-cases sniff protocol names and ignores the deprecated `sniffing` list once `sniff` holds anything, so\n`mergeSnifferOverride` drops the profile's entry for an overridden protocol in any case and first carries `sniffing`\ninto `sniff`. A `rule-set:` key of a custom profile's DNS policy counts as a use of that rule provider, so\n`_resolveInjectedProviders` injects it like one a rule names. Safe mode forces `write-to-system` off on whatever `ntp`\nsection reaches the core, because setting the system clock is a host change; `loadConfig` in the Go wrapper also drops\nit on Android, where the seccomp policy kills the process for `settimeofday`.\n\nTUN is the exception to covering the raw struct. The network settings own only `Tun.coreJson` (`enable`, `device`,\n`stack`, and `auto-route` always on), spread last over the profile's section and patched again on every\n`updateConfig`, so `TunOverrideKey` offers only keys outside it that work in FlClash's TUN. Its keys exist app-wide\n(`PatchClashConfig.tunOverride` with `tunOverrideKeys`, listed in the network page's TUN section as `OverrideEntry`\nrows and added from its last row, as a custom group's options are) for every profile, and per custom profile, whose own keys `ProfileOverrides.overAppTun` lays over the app-wide ones. MTU,\ncongestion control, strict route and the route lists are such keys on desktop, so a profile's own values apply unless\npicked; Android's TUN rows keep them in `Tun`, which its VPN service starts from, and a config saved before the move\ncarries values changed from their defaults into picked keys. `bypassPrivateRoute` stays a network setting: the task\nappends the private ranges to whatever `route-exclude-address` results. `dns-hijack` is a key too: the task writes\n`any:53` beneath the overrides, so a profile's own value never applies, a picked key replaces it, and the key starts at\nthat value. A key sing-tun reads on some platforms only is listed only there\n(`_isRead` in `lib/views/config/tun.dart`): strict route on Linux and Windows, GSO and the interface, user ID and port\nfilters on Linux. `udp-timeout` reaches only the gvisor and system stacks and congestion control only mips, which their\ndescriptions say. Route address sets are left out: sing-tun applies them only through `auto-redirect`.\n`test/models/tun_override_test.dart` fails if a key comes to overlap `Tun.coreJson`. Android starts its VPN from\n`core/tun` without the section, so TUN is listed on desktop only. Being a part of the section, the page has no quick\nedit; see Quick Edit.\n\nA custom profile's overview keeps rows for rules, providers and DNS, which every custom profile runs on, and puts the\ndialer proxies, NTP, sniffer and TUN behind a single \"more\" row, which opens a page listing them with their states.\nBoth lists are the same grouped rows, glyph, title and state, ending in a chevron. Another rarely changed section joins\nthe \"more\" list rather than taking a row of its own.\n\nEvery page is an `OverrideView` over an `OverrideSpec` from `lib/views/config/override.dart`. The spec supplies the\nsection's keys, labels and fields; its `OverrideTarget` is where the model and keys live, `PatchOverrideTarget` for the\napp-wide DNS and `ProfileOverrideTarget` for a profile. Another overridable section is a spec, not a copy of a page.\n\nA config from a release that still had the `overrideDns` toggle keeps, through `Config.fromJson`, the normal keys of\nan on toggle and none of an off one. That release had no custom profiles, so its app-wide NTP override is dropped.\n\n## Listener Exposure\n\n`_makeRealProfileTask` in `lib/common/task.dart` writes `mixed-port`, `allow-lan`, and `external-controller` into every\ngenerated profile. With `allow-lan` off, mihomo's `genAddr` binds each listener to `127.0.0.1`; the external controller is\n`ExternalControllerStatus.close` by default and never binds anywhere but loopback. The task also clears\n`external-controller-tls`, `-unix` and `-pipe` in every mode: the unix and pipe controllers answer without a secret, and\nthe unix one unlinks whatever file sits at its path before opening it to everyone, so a profile may not choose them.\nOutside safe mode, profiles retain `listeners`, `tunnels`, `ss-config`, `vmess-config`, `tuic-server`, `iptables`,\n`dns.listen` and `ntp.write-to-system`. Extra inbounds can bypass the app's LAN and authentication settings, and a TUN\nlistener can change routing independently of the app's TUN toggle. These network and clock effects are an accepted\nboundary; they are not treated as arbitrary filesystem modification. The task disables `iptables` when its\n`inbound-interface` contains a space, because mihomo interpolates it into commands and splits their arguments on spaces.\nSafe mode clears these extra inbounds and iptables, closes DNS listening and disables NTP system time writes even after\nlocal overrides. The editor omits disabled alternate controllers and keeps listener completion.\n\nAn open external controller always has a secret, because mihomo serves a controller with an empty one to every local\nuser and, through CORS, to any page the user visits. `PatchClashConfig.secret` is that secret and it belongs to FlClash,\nas the address does: the task writes it over whatever the profile carries, and `UpdateParams.secret` hands the same value\nto a running Core, which restarts the controller when it changes. `ensureControllerSecret` keeps the pair consistent\n(an open controller gets a generated secret, a closed one drops it, so every opening rotates it) and runs where a\nconfig enters the app: the settings toggle, startup, and `writeConfig` for a restored backup. `_effectivePatchConfig`\nreports a controller without a secret as closed, and `_makeRealProfileTask` independently closes it without a secret\nor in safe mode, so a writer that skips `ensureControllerSecret` opens nothing. The settings page shows the secret in\nan editable row, which refuses an empty value.\n\nThe loopback listener takes local connections without authentication by default: mandatory credentials would lock out\nevery external local consumer a proxy client exists for. The binding only guarantees traffic originates on the device;\nit does not isolate programs already running there, so on Android any app holding `INTERNET` can use the proxy and learn\nthe outbound IP (#1934). The opt-in answer is the local authentication setting (`NetworkProps.authentication`), which spans\nfour paths that must stay in sync: `_makeRealProfileTask` writes the credentials into `authentication` and force-clears\n`skip-auth-prefixes` so a profile cannot silently exempt loopback; `UpdateParams.authentication` applies the same list\nto a running core; `FlClashHttpOverrides` sends the credentials with the app's own proxied requests; and `sharedState`\nwithholds the Android VPN system proxy declaration because `ProxyInfo` cannot carry credentials (TUN still captures that\ntraffic). Desktop system proxy is deliberately not gated — dropping it would leak traffic direct, so the setting's\ndescription tells users to supply credentials manually instead.\n\n### Local Trust Boundary\n\nThe Windows Helper answers only a connection whose client process runs under the owner SID it was installed for;\nthe Linux Helper only the owner uid. A setuid Core is executable by `root:admin` (macOS) or the installing user's primary\ngroup, so its boundary also includes every member of that group, not just the installing account. Profiles cannot choose\na controller address, a secret or alternate controllers. Another account outside those permissions, a subscription,\nor a remote party reaching the privileged Core's control or filesystem APIs is a vulnerability.\n\nA process running as the same account is not stopped. It can ask the Helper to start the Core\nagainst a socket or pipe it owns, or run a setuid Core against a port it listens on, and so become the host that drives\na privileged Core: `initClash` chooses the home directory every path check anchors on, and `getConfig`,\n`getProfileBase`, `getProfileOutbounds` and a `setupConfig` or `buildProfile` base read any path.\nThe Core connects to whatever address it is given and cannot tell that host from FlClash. An account check cannot\ndistinguish it from a same-account process that can already read FlClash's memory and files. This remains a user-to-root\nor user-to-SYSTEM privilege escalation, not a complete security fix. An open external\ncontroller is inside the same boundary: its secret is stored under the account, and `/upgrade` stays mounted (it fetches\nfixed MetaCubeX release URLs, not caller-chosen code). mihomo's `IsSafePath` compares paths lexically, so a symlink inside\nthe home directory escapes it; that fix belongs upstream, not in the submodule.\n\nClosing the same-account case requires limiting what a hijacked Core can do. Keeping the main Core unprivileged and\nmoving host changes into narrow platform helpers requires architectural work, but need not introduce repeated prompts\nor remove ordinary proxy functionality. Features that require additional privileges, such as identifying other users'\nprocesses, need platform-specific handling. Descriptor-based filesystem confinement can preserve ordinary managed\npaths; intentional external symlink targets need explicit allowed roots. These changes have not been implemented.\nAccount authentication, a private socket directory or a symlink check at configuration time does not remove the\nprivileged filesystem operations available to the host. Changes must not widen access to those operations.\n\n### Interface Name Modes\n\nThe same task also writes `interface-name` per `PatchClashConfig.interfaceNameMode`: `clear` (default) forces it empty so a\nsubscription's value cannot survive into the generated profile, `follow` leaves whatever the profile YAML already has, and\n`custom` writes the user's `interfaceName`. mihomo ignores `interface-name` on Android whenever `dialer.DefaultSocketHook`\nis installed (`core/lib.go` `installHooks`, vendored `dialer.go`), so `NetworkListView` in `lib/views/config/network.dart`\nshows the mode picker and text field only on desktop.\n\n### Network Settings\n\n`Tun` holds what Android's VPN service starts from: the switch, device and stack, `mtu`, the mips stack's\n`congestion-controller` and the route lists. The desktop TUN takes only `Tun.coreJson` from it, written over the\nprofile's `tun` section and patched on every update through `tunSchema`; MTU, congestion control, strict route and the\nroute lists reach it as TUN override keys (see DNS, NTP and Sniffer Overrides). `test/models/tun_test.dart` keeps every\n`Tun` key within mihomo's `RawTun` and `coreJson` equal to the keys of `tunSchema`.\n\n`NetworkProps.bypassPrivateRoute` adds `privateRouteAddress` to the excluded routes, through `TunExt.excludedRoutes` on\nAndroid and `_makeRealProfileTask` on desktop. VpnService can only add routes before Android 13, so\n`Tun.vpnRouteAddress` cuts the exclusions out of the routes with `subtractCidrs` and lists every family it routes;\n`VpnService.addRoutes` routes everything only for an empty list. A config saved with the old `routeMode` keeps its\nchoice: `bypassPrivate`, which ignored the route list, loads with the flag on and the list cleared, and `config` with\nthe flag off. No stack in the vendored sing-tun reads `endpoint-independent-nat`, so it is not offered. Android builds\nits TUN from the VpnService descriptor in `core/tun/tun.go`, so only MTU and the congestion controller reach it,\nthrough `VpnOptions` and the `options` argument of `startTun`; the VpnService builder takes the same MTU.\n\nThe TUN fields start at mihomo's own defaults, and a saved zero MTU or empty congestion controller, which mihomo reads\nas those defaults, decodes to them. The outbound rows are plain settings written over every profile, and changing one\nre-applies the profile; keep-alive is forced off on Android, and only Linux honours `routing-mark`.\n\n## Lifecycle Ownership And Convergence\n\n### Shared Flutter Layer\n\n`CoreController.start()`, `restart()`, `stop()`, and `close()` are the only shared lifecycle facade. `close()` is terminal;\ncallers must not try to reuse a closed platform implementation.\n\n`CoreAction` in `lib/providers/actions/core.dart` owns the user-facing Core status and setup sequence:\n\n- `startCore()` publishes `connecting`, starts the platform Core, publishes `connected`, then initializes Core state. A\n  startup error publishes `disconnected` and displays the error.\n- `restartCore()` coalesces overlapping callers behind one worker. `_requestedRestartRevision` records newer requests.\n  After the lifecycle restart, the worker reapplies running state or the profile, rereading `isStartProvider` on every\n  pass, until it has consumed the latest revision, so the newest start intent wins.\n- The provider is an orchestration and presentation layer, not a process owner. Platform lifecycle code remains responsible\n  for determining whether a Core process/service is actually running.\n\nApplication exit is centralized in `SystemAction` and `SystemExitCoordinator`:\n\n1. Optionally save config and clean up DNS, system proxy, and tray resources in parallel.\n2. Close the desktop window.\n3. Call terminal `CoreController.close()`.\n4. Exit the application exactly once.\n\nThe coordinator is idempotent, continues later cleanup steps after an earlier error, preserves the first error for the\ncaller, and uses a three-second watchdog as an emergency application-exit path. `Application.dispose()` and\n`CoreManager.onCrash()` do not independently destroy Core; this avoids competing shutdown owners.\n\n### Applying Setting Changes\n\nA changed setting is patched live through `UpdateParams`, re-applied with the profile, or, for the Android VPN, left\nto a restart the user confirms. [setting-changes.md](setting-changes.md) says which path each setting takes and how to\nchoose one for a new setting.\n\n### Desktop Lifecycle\n\n`DesktopCoreLifecycle` is a latest-desired-intent reconciler, not a queue that blindly executes every request:\n\n- Public intents receive monotonically increasing revisions and target running, restarted, stopped, or closed.\n- Observable phases are `idle`, `starting`, `running`, `stopping`, `failed`, and `closed`.\n- A completed command reports `applied`, `coalesced`, or `superseded`, allowing callers and tests to distinguish a command\n  that won from one satisfied or replaced by a newer compatible intent.\n- Startup opens or replaces the IPC transport, resolves a launcher, generates a 128-bit lowercase hexadecimal session ID,\n  launches Core, and waits for the matching connection. Windows additionally verifies that the named-pipe peer PID equals\n  the process PID returned by the Helper lease.\n- Each running session retains its process owner, lease, PID, session ID, and transport connection generation. Stop waits\n  for both process-exit confirmation and the matching disconnect generation; a missing disconnect replaces the transport\n  before later starts.\n- An unconfirmed process exit is retained as an unconfirmed lease. New start/restart intents fail until ownership can be\n  cleaned up, preventing two Core instances from being treated as the active session. Terminal close may continue on a\n  best-effort basis because the application is exiting.\n- An unexpected disconnect or transport failure while running is converted to `DesktopCoreFailure`, the owned process is\n  cleaned up, and `CoreService` emits a Core crash event for the normal UI recovery path.\n\nDirect launch is used on macOS, inside an AppImage, and as the Windows/Linux fallback when the privileged Helper is not\nready. When the Helper is ready, it owns the Core child and Dart owns it through a session-scoped lease.\n\n### Android Service Lifecycle\n\nAndroid deliberately keeps Flutter requests optimistic and the native layer authoritative:\n\n- `ServicePlugin.start()` and `stop()` acknowledge immediately after submitting intent. They do not wait for service\n  creation, VPN permission, binding, TUN establishment, or teardown.\n- `ServiceState` owns the latest `RunRequest` and the shared configuration. Identity checks discard obsolete work.\n  `startPreparationLock` serializes permission/setup preparation and `transitionLock` serializes actual service\n  transitions.\n- `RunState` is derived, never assigned by a step: `ServiceStateMachine.publish` computes it from the transition in\n  flight, the latest intent, and `ServiceController.runtime`. A service that is up under a stop intent reads `STOPPING`, a\n  start still waiting on a permission reads `STOPPED`, and `STARTED` needs both a running intent and a runtime. The tile\n  and `getRunTime` both read this one value, so they cannot disagree with the service.\n- `ServiceController` owns exactly one `ManagedServiceBinding`, selects `VpnService` or `ProxyService` from `VpnOptions`,\n  binds with a five-second connection timeout, and invokes `ManagedService.start()`/`stop()` off the main thread. The\n  binding and `runtime` (which service runs, and since when) are set and cleared together, by a stop, a failed start, or\n  a lost connection and by nothing else. Flutter's `shutdown` only clears the Core event listener: Android keeps an\n  established `VpnService` alive through its own connection, so a binding dropped on exit left a tunnel that was\n  reported as running but that neither a stop nor a revoke could reach.\n- The Core listens exactly while `runtime` is set: a successful start ends in `Core.startListener()` and every path\n  that clears `runtime` calls `Core.stopListener()`. Both return at once when the Core is already there, which is the\n  usual case, since `startTun`/`stopTun` and Flutter's own `startListener`/`stopListener` got there first. They matter\n  when nothing else does: a proxy-only run stopped from the tile used to keep the mixed port open with nothing\n  reported as running. A start closes after itself the same way: Flutter's `startListener` and the native `quickSetup`\n  both open the ports before any service exists, so a start that ends without a `runtime`, refused or cancelled, stops\n  the empty service once more, without passing through `STOPPING`. Flutter may be gone by then and never see the\n  `stopped` report.\n- A start that does not bring the service up gives its intent back and takes down whatever it left running, unless a\n  newer request has claimed the service. A failed start therefore always ends in `STOPPED`, whichever step refused it.\n  The one start that mints no request is a repeat on a settled run of the same kind, which Flutter sends every time it\n  reattaches: a request refused at a permission prompt there would stop a service that was running fine.\n- Generic service creation/destruction is lifecycle evidence, not user intent. New commands must flow through\n  `ServiceState.requestStart()`/`requestStop()` or the explicit system-action handlers instead of inferring intent from a\n  callback.\n\nQuick Settings, notification, revoke, and Always-on VPN paths converge on the same owner:\n\n- With a Flutter engine attached, `ServiceState.handleStartAction()` forwards through `TilePlugin` to `TileManager`,\n  because only Flutter can apply the current profile. Without Flutter, native code claims the intent, restores\n  `SharedState` from preferences, runs `quickSetup`, checks VPN permission, and runs the start; claiming first is what\n  makes a second action during the setup toggle that start off instead of racing a second setup against it.\n- An engine that attaches to a run the service already has, started that way or outliving the last engine, adopts it:\n  `SetupAction.initStatus` restores the md5 of the config.yaml the Core last accepted and pushes the profile only when it\n  now builds differently. Pushing the same config again rebuilds every proxy group, and mihomo keeps a url-test group's\n  unmeasured pick for ten seconds, so the dashboard's exit and service checks ran a second time once it moved.\n- A stop never needs Flutter. `handleStopAction()` stops the service natively whether or not an engine is attached, so\n  the tile, the notification action and a revoke work even when Flutter's own idea of the state is wrong.\n- Whenever the run ends without Flutter having asked for it (a start that failed, a native stop, a revoke, a lost\n  service), `ServicePlugin` sends `stopped` with the revision of the last `start`/`stop` command it received.\n  `Service` in `lib/plugins/service.dart` drops a report older than its newest command, or one that arrives while its\n  last command was a stop (on-demand suspension stops the service while `isStartProvider` stays true), and\n  `AndroidManager` answers the rest with `setRunning(false)`. The report is read on the platform thread, where the\n  commands arrive, so the revision and the intent it carries describe the same instant.\n- Android may create an Always-on `VpnService` through `onStartCommand()` without FlClash's bound-service path. The service\n  sends the explicit, permission-protected `VPN_START_REQUESTED` broadcast to `ServiceBroadcastReceiver`, which routes it to\n  `ServiceState.handleStartAction()` so Core/configuration and the normal binding are restored before TUN is treated as\n  ready.\n- `VpnService.onRevoke()` stops TUN/modules first, then sends `VPN_REVOKED`; the receiver only requests a stop while\n  `ServiceController.runtime` is a VPN run.\n- `ServiceBroadcastReceiver` uses `goAsync()` and an atomic one-shot completion. Normal completion or a nine-second\n  watchdog calls `PendingResult.finish()` exactly once; the watchdog releases Android's broadcast lease and does not\n  cancel or redefine the underlying lifecycle intent.\n\n## Core Protocol And Event Delivery\n\nThe shared protocol uses `CoreMethodCall(id, method, arguments)` and `CoreMethodResponse(id, result, error)` in both\ndirections. The envelope is the only JSON serialization layer: keep arguments, results, and event data as structured JSON\nvalues rather than embedding pre-encoded JSON strings. Plain domain strings, such as country codes or provider contents,\nremain strings.\n\nA response over 1 MiB goes to a file under `<home>/responses/`, and the envelope carries only its `file`\n(`core/spill.go`). Sent inline, Android copies it into the Java heap three times (the gomobile `byte[]`, the codec and a\ndirect buffer), and desktop IPC drops a frame above `maxIPCFrameSize`. `lib/core/spill.dart` reads a spilled file only\nfrom that directory, decodes it in an isolate and deletes it; `initClash` clears what an earlier run left.\n\nA profile file's proxies, groups and rules never cross the bridge. `getProfileBase` returns the config without\n`proxies`, `proxy-groups`, `sub-rules` and `rules`, with their counts, the profile's MATCH rules and a sha256 digest.\n`SetupAction.getProfile` patches it as before and sends the result as `SetupParams.base.overlay`. `core/profile_base.go`\nputs the profile's bulk sections into the overlay, appends the profile's rules after the overlay's, writes `config.yaml`\nfor the Android service and the controller's reload, and parses it. `config.yaml` is encoded a slice at a time, because on a\nprofile of 100k proxies and 2M rules one `Encode` call doubles the peak heap. The preview reads the YAML `buildProfile`\nmerges into the spill directory, and the extend page only the names\nand types `getProfileOutbounds` lists; a script extension still needs the whole config from `getConfig`.\n\nGo event delivery is intentionally non-blocking:\n\n- Loaded-provider, geo-update, and route-changed events use a state queue that never evicts; the rules for it are under\n  Core API Safety in `.agents/rules.md`. Every other event except the bulk kinds, delay included, uses a 256-entry\n  priority queue. Desktop process crashes are generated locally by `CoreService` from lifecycle failures rather than\n  sent through the Go queue.\n- High-volume log, request/connection, and DNS query events use a separate 256-entry bulk queue, so bulk floods cannot\n  evict state.\n- A full priority or bulk queue evicts only its own oldest event and retries the newest; DNS events never evict. Core\n  work never blocks on event delivery.\n- The batcher flushes at 32 messages or every 16 milliseconds. Priority events are preferred, but one bulk opportunity is\n  guaranteed after eight priority messages to prevent starvation.\n- `sendMessage` drops an event before it is queued when `hasEventListener` is false. That only happens on Android, after\n  the Flutter engine detaches while the service keeps the Core running; request and DNS events would otherwise be\n  encoded per connection and per query for nobody.\n\nDNS query events come from the Clash.Meta fork's `dns/patch.go` and are raised where mihomo writes its `[DNS]` debug\nlog, so the DNS page lists the same resolutions: one event per cache hit, and one per upstream exchange however many\ncallers singleflight merged into it, carrying the upstream whose answer was used. Fake-IP and hosts answers never reach\na resolver and are not recorded. Queries from apps arrive with the `DNSContext` that `Service.ServeMsg` created and\nreport `app`; other initiators come from `resolver.WithInitiator` at the call site (rule matching, DIRECT, proxy\nserver dialing), and unlabeled queries report `other`.\n\nDialer-loop events come from the fork's `component/proxydialer/patch.go`. mihomo follows a `dialer-proxy` that leads\nback to its own proxy until the stack overflows, so a dial that asks the same `dialer-proxy` for an address it is\nalready dialing through it fails with `ErrDialerLoop`. `core/hooks.go` reports each looping name at most once every\nthree seconds as a priority event, which the app shows as an error notice whether or not logs are captured. The custom\nprofile pages flag only the loops FlClash can follow on its own, through a group's own list or an `include-all-proxies`\nthat no filter, exclude filter or exclude type narrows; what filters and providers pick, including provider proxies\nthat carry their own `dialer-proxy`, is left to this guard.\n\n`ProxyGroup.fromJson` reads `include-all` as both `include-all-proxies` and `include-all-providers`, as the core does, so\nimport and quick edit keep it. They take in only what the config declares, which is what some group of the profile\nnames: its local proxies and the file or url profiles in a `use`. Setting one declares nothing more, so the app's other\nproxies and subscriptions stay out, and the core picks from the declared ones by the group's filters. Groups saved by\nv0.9.0 kept `include-all-proxies` in their stored `definition`, and it takes effect again.\n\nDesktop RPC accepts both a single event object and batched event lists. Android and desktop listener dispatch isolate\nlistener exceptions so one faulty observer does not prevent the remaining events/listeners from running.\n\n## User-Facing Core And Delay Feedback\n\n`CoreStatusButton` in `lib/views/dashboard/widgets/core_status_button.dart` is the desktop dashboard's status/restart\nsurface. It is shown only outside dashboard edit mode and only when `coreLib == null`:\n\n- Provider state remains authoritative. The widget keeps a separate display-only status so a fast\n  `connecting -> connected` transition still shows at least 600 milliseconds of progress instead of flashing.\n- The hold arms only after an observed transition to `connecting`; mounting while already connecting does not invent a new\n  delay. A real `disconnected` transition cancels the hold immediately so failure is never hidden, while a long-running\n  connecting state remains visible after the timer expires.\n- Taps during the display hold or while the provider is genuinely connecting are inert. Connected/disconnected taps show\n  the appropriate confirmation and delegate restart to `CoreAction`; the widget never starts Core directly.\n\nProxy delay testing follows the same failure-safe UI rule. `ProxiesAction._runDelayTests` marks its keys in\n`pendingDelayTestsProvider` and releases them in `finally`, and a test the Core does not answer leaves the last\nmeasurement in place; the delay-test bullets under Core API Safety in `.agents/rules.md` give the reasons. A key stays\n`queued` until the `TaskPool` actually runs it, so node cards animate a loader for at most `maxConcurrentDelayTests`\nnodes and show a static glyph for the rest; a loader on every queued node made fast scrolling drop frames. A group's delay\ntest button, the tab page's action and the list page's group header alike, spins while `delayTestingGroupsProvider`\nholds the group; `delayTestPageGroup` marks it, ignores a second request for the same group, and clears it in `finally`,\nso an RPC failure cannot leave the button spinning and remounting a header does not lose the state.\n\nThe dashboard's probe cards show a failure as still checking for five seconds after they see `proxied` turn true,\nthrough `ProbeStartHold` in `lib/views/dashboard/probe_start_hold.dart`: `ServiceStatusCard` holds the check on each\ntile, and `CurrentOutboundCard` holds the exit IP, whose refresh button stays inert meanwhile.\n`SetupAction.setRunning` flips the run time, and with it `proxied`, before the Core has brought its listeners up, so the\nprobe launched on the spot often fails, and the config re-apply that follows every start probes again moments later;\nwithout the hold a card flashes a failure and then takes it back. The hold is local display state like the connecting\nhold above: the cache still records the failure, the hold arms only on an observed transition, a result that is not a\nfailure shows at once, a stop cancels it, and a failure still standing when it ends is shown.\n\n## Route Consistency\n\nA probe result on the dashboard — the exit IP, a node's exit IP, a service check — either describes the route in\nforce or is being asked again. The mechanism has three parts.\n\n- The Core is the source of truth for the route. `core/route.go` keeps two counters. The epoch is a reset, which\n  no answer outlives: a config apply, an IPv6 switch, and a start (the listeners coming up and, on Android, the TUN\n  after them). The route version is a move, which may send a host to another exit: a Selector, URLTest or Fallback\n  pick changing, a proxy provider whose version changed, a rule set that loaded new content, a GeoIP or GeoSite\n  update, and a mode switch. `handleChangeProxy` re-reads the picks under `selectMu` and answers\n  `ChangeProxyResult{changed}`, so the connection reset only runs when the pick moved. A health check moving a\n  URLTest pick has no mihomo hook, so the picks are polled every second while the host watches (`watchRoute(true)`)\n  and the listeners are up; the same poll notices a provider or rule set that updated in the background, which in\n  this fork includes every provider an apply loads, since `loadProvider` returns before they finish. Every probe answer\n  carries the counters it started under and its hits: each host it dialed, the proxy it was pinned to, and the exit\n  it left through, read off the tracked chain or, for a connection that never reached the tracker, matched again.\n  An exit is the last hop with its server address, or a load balancer, which spreads connections without the route\n  moving. `checkRoutes` matches those hosts again through `tunnel.ResolveRoute`, a fork patch that runs the rules\n  without dialing, and reports per answer whether every exit held. `routeChanged` publishes the counters whenever\n  they differ from the last read. A start resets the epoch because `isStartProvider` flips before the Core is told,\n  so the probes sent at the tap leave ahead of the TUN routes and over DNS connections `handleStartListener` resets,\n  and an Android start acknowledges before the VPN exists. Outbound IP lookups are cancelled by the first lookup\n  under a newer epoch, which frees their probe slots; lookups under one epoch are left alone, since the routed\n  target while stopped and a pinned `DIRECT` send the same request.\n- `routeTrackerProvider` in `lib/providers/route_state.dart` merges that with the host's side: `proxied` (running and\n  not suspended), a host epoch that a connectivity event moves, and `synced`, which is false until `watchRoute` has\n  returned a snapshot and again after a Core disconnect. Nothing is fresh while unsynced, so a cache never judges\n  answers against counters it made up. The tracker subscribes when the first target is watched and releases the Core\n  when the last one goes; an answer newer than the state means an event was evicted, and the cache asks for a resync.\n  It also releases the Core while the app is hidden and reads it again on return, keeping `synced`: the Core and its\n  counters are the same ones, so the fresh snapshot invalidates only what moved. On Android the Core drops the watch\n  itself when the event listener is cleared, because an engine that goes away never sends `watchRoute(false)`.\n- `RoutedProbe` in `lib/providers/routed_probe.dart` is the one cache both `OutboundIpProbe` and `ServiceStatus` mix\n  in. A widget `watch`es the target it shows and `unwatch`es on the way out; only watched targets are probed, one\n  request in flight per target. An answer is stamped with the Core's counters, its hits and the host state at\n  launch. A reset (`RouteState.hasMovedSince`: the Core epoch, the host epoch or `proxied`) invalidates everything:\n  a stale watched entry shows as checking at once and is asked again after `probeMoveSettleDuration`, since a start\n  moves it three times within about 150 ms (`proxied`, then the listeners, then the Android TUN) and the user is\n  watching for the new address. A new route version keeps every answer on show and, once `probeSettleDuration` has\n  passed the next pick poll, sends their hits to `checkRoutes` in one call; only an answer whose exit moved is\n  dropped and asked again, and one that held takes the checked version. An answer that lands under an older version\n  than the state is held back as checking until its own check returns, so it never shows and then flips. An ask\n  that is not the user's or a reset's waits out `probeThrottleDuration` since the target's last probe, so a config\n  load whose providers, rule sets and url-test picks land over a few seconds moves an exit at most one probe more.\n  A `refresh` waits for neither, and an unwatched entry whose exit moved is dropped. Nothing launches or checks\n  while the app is hidden: what falls due waits for it to be shown.\n  Failures wait for `retry`, `refresh`, or the app resuming (`markResumed`), and\n  a service check that timed out is a failure that still shows its status. `refresh` is the user's own request and\n  runs for unwatched targets too: the service sheet reads the cache and never watches its rows, because one service\n  check is one to four full HTTPS fetches where a delay test is a single empty request.\n\n`ipQuality(ip)` in `lib/providers/ip_quality.dart` is keyed by the outbound IP found, so a route change reaches it as\na new IP and an unchanged IP reuses the result. A lookup races its sources the way the IP check does and takes the\nfirst one that determines a type. A success is saved to the `IpQualities` table and answers for that IP, across\nrestarts, until `ipQualityMaxAge` (three days) has passed; a shown result is asked again when it expires,\nand invalidating the provider (the sheet's check again) skips the saved record. A failure is not saved, and Riverpod's\nautomatic retry is off because a retry after an HTTP 429 spends the same per-outbound-IP quota again.\n\n## UI Layer\n\n`lib/ui/` holds the components and design tokens that know nothing of the app: shapes, surfaces, motion, colors, the\nscaffold, sheets, popups, lists and the glyphs under `lib/ui/icons/`. `lib/widgets/` holds the components built from\nthem that do: settings rows, subscription and profile pieces, anything that reads a provider or a model.\n`widgets/widgets.dart` re-exports `ui/ui.dart`, so app code imports either barrel.\n\n`test/lint/ui_layering_test.dart` walks the imports of every file under `lib/ui`. Nothing they reach may leave\n`lib/ui`, except `lib/common` files whose own imports stay out of the app, never the `common.dart` barrel, and none\nmay import Riverpod. A component that needs app state reads what the app provides above its navigator:\n\n- `ViewScope` (`lib/ui/view_scope.dart`): the window size, whether the layout is mobile, and the window controls\n  over a page. `AppViewScope` in `lib/manager/theme_manager.dart` fills it from `viewSizeProvider`,\n  `isMobileViewProvider` and `versionProvider`. It is an `InheritedModel`, so a reader of one value does not rebuild\n  when another changes; `context.isMobileView` reads it without listening.\n- `MeasureScope` (`lib/ui/measure.dart`): the `Measure` that `ThemeManager` also assigns to `globalState.measure`.\n- The `MediaQuery` text scaler that `ThemeManager` sets: the app text scale behind `.ap(context)`, `.mAp(context)` and\n  the `...Of(context)` paddings in `lib/ui/metrics.dart`. Reading it there makes a widget sized by them rebuild when the\n  scale changes; a global copy or a top-level final would keep the scale it was first read at.\n- `UiLocalizations` (`lib/ui/l10n.dart`): the strings the components show themselves. `AppUiLocalizations.delegate`\n  in `lib/common/app_localizations.dart` serves them from the ARB strings; a new one needs a getter on both.\n\nA test that builds its own `MaterialApp` registers `AppUiLocalizations.delegate` and returns `wrapAppScopes` from\n`test/helpers/test_app.dart` in its builder, as `TestApp` does.\n\n## Icons\n\n`lib/ui/icons/` holds every icon the app shows. Nothing in `lib/` uses the Material icon font, and\n`test/lint/material_icons_test.dart` fails on any `Icons.*` or `AnimatedIcons.*`, so every icon shares one stroke and\ncan animate its shape.\nA `Glyph` is a list of shapes on a 24-unit grid, stroked at `Glyph.strokeWidth` with round caps and joins and\nsuperellipse corners; a curve the primitive shapes cannot draw is a `GlyphPath`, SVG path data traced along the\nstroke's center. Each shape has a role that decides its filled form, the way SF Symbols and Fluent pair an\noutlined icon with its filled variant:\n\n- `body` fills;\n- `detail` is cut out of the fill, and a `solid` detail such as a dot is cut as an area;\n- `line` stays a stroke, for parts that belong to no body, like a handle or a link between nodes; it thickens from\n  `Glyph.strokeWidth` toward 2.2 as the glyph fills, so a glyph of lines alone still carries weight in a filled button.\n  A `solid` line, such as the dots of `AppGlyphs.more`, scales by the same ratio.\n\n`GlyphPainter` paints any point between the two forms. Each body fills inward from its outline, reaching half its\nshorter side, which no point of the body lies beyond, as `fill` reaches 1. Details thin away over the first half and\nreturn as cutouts over the second, so a detail is never a stroke and a cutout at once. The glyph is painted opaque into\none layer that carries the color's alpha: a translucent color, such as a disabled one, then stays even where strokes\ncross the fill, and a cutout clears only the glyph's own pixels. `GlyphIcon` shows a glyph at a fixed `fill`, taking\nits size, color, and fill from `IconTheme` unless it is given its own, as `Icon` does, and mirroring it in right-to-left\ntext when `Glyph.matchTextDirection` is set.\n`AnimatedGlyph` drives a `GlyphIcon` from its `filled` flag and pops briefly when it fills. A glyph whose shape follows\nsome other state, like the sidebar toggle that tracks the pane's width, is a builder in `AppGlyphs` such as\n`AppGlyphs.sidebar(progress)`. A new glyph goes in `AppGlyphs` and in the list `test/icons/glyph_painter_test.dart` runs\nthrough its checks; it is named for what it means in the app, one glyph per meaning, with SF Symbols and Fluent as the\nreference for the metaphor. Tests find one with `find.byGlyph` from `test/helpers/glyph_finders.dart`.\n\nIcons are outlined by default and filled inside a filled button: `IconButton.filled` and `.filledTonal`, and\n`FilledButton` in its tonal and icon forms draw their glyph with `fill: 1`, because an outline on a tinted\ncontainer reads weaker than the container it sits in. A glyph with no body only thickens its lines and still takes\n`fill: 1`, so the rule holds if the glyph later gains one. `test/lint/filled_button_glyphs_test.dart` checks every\n`GlyphIcon` written inside such a button; an icon that reaches the button from elsewhere, as through a helper that picks\nthe button variant, gets its fill by hand or from `IconThemeData.fill`. Everywhere else fill marks state only: the\nselected destination, a pinned window, a lit torch. The phone's floating navigation bar is the one exception: like an iOS\ntab bar it fills every destination and marks the selected one by colour alone.\n\nFramework widgets that draw their own icon take it from the theme: the `ActionIconThemeData` in `lib/application.dart`\nsupplies the back and close buttons, with `AppGlyphs.backFor` picking the platform's back glyph, and a `DropdownMenu`\nis given its chevrons. A framework widget added later that paints a Material icon needs the same treatment.\n\nEmpty states draw no image assets either. `NullStatus` in `lib/ui/null_status.dart` shows the glyph for its\nmeaning filled on a Material 3 Expressive shape from `material_new_shapes`, with a few accent marks, and enters with\none of three choreographies picked at random: the shape spins in, morphs out of a circle, or springs in while the\naccents orbit into place. The glyph takes `onPrimaryContainer`, not `primary`: the `content` scheme variant can leave\n`primaryContainer` as light as `primary` in dark mode.\n\n`CaptionIcon` is the one icon that does not follow the glyph grid: the window caption buttons keep the one pixel,\npixel-aligned stroke of Windows' 10-pixel caption glyphs on every platform.\n\n## App Bar Actions\n\n`CommonScaffold` keeps at most three buttons in sight in its bar, the overflow menu button included. A page hands it\nwhat may fold as data: `primaryAction`, `iconActions`, `menuItems`, and `searchActions` while searching; custom widgets\npassed through `actions` never fold. Past three, two buttons stay beside the overflow menu: the search button when\nthere is one, then foldable actions, primary action first;\neverything else goes into one overflow menu, primary action first, and a folded action that is loading shows as a\ndisabled item. The bar reads as one button group of two beside a single button: the overflow menu button when\nsomething folds, otherwise the last action, so the proxies list shows a search and delay test group beside the menu.\nThe bar holds `primaryAction` everywhere, a phone's home page above the fixed navigation bar included, and nothing in\nthe app floats an action button: a secondary a\n\n<!-- File: .agents/skills/core-platform/SKILL.md (Tokens: ~963 | Category: anthropic-skill) -->\n---\nname: core-platform\ndescription: 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.\n---\n\n# Core And Platform\n\n## When To Use\n\nUse this for changes touching `lib/core/`, `lib/manager/`, `core/`, `services/helper/`, Android app/service modules,\nbuild hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior.\n\n## Read First\n\n- `.agents/rules.md`: Core API Safety and Lifecycle Rules are binding. They carry the mihomo-fork patch budget, the\n  JSON envelope and `core/message.go` queue rules, gomobile callback rules, `BootGuard`, tray, and SSID rules, and every\n  lifecycle invariant (latest intent wins, desktop ownership through a `CoreProcessLease`, terminal `close()`,\n  optimistic Android commands arbitrated by `ServiceState`, callbacks are not user intent, `goAsync()` finishes once).\n- `.agents/setting-changes.md`: a setting the Core or the Android VPN reads is patched live, re-applied with the\n  profile, or left to a restart the user confirms; it says which, and what adding a setting to each path takes.\n- `.agents/architecture.md`: Core Integration, Lifecycle Ownership And Convergence, Core Protocol And Event Delivery,\n  Safe Mode, Build System, and Rust Helper Service describe the current mechanisms, including the Helper protocol,\n  the applied/coalesced/superseded outcomes, and the unconfirmed-exit lease.\n\n## Owners\n\nEvery behavior here has one authoritative owner, and a change lands there:\n\n- Shared facade/protocol: `lib/core/controller.dart`, `lib/core/interface.dart`, and `lib/core/method.dart`.\n- Android Core connection: `lib/core/lib.dart`, `lib/plugins/service.dart`, and Android `ServicePlugin`.\n- Android start/stop intent: `ServiceState`; binding/process-time bookkeeping: `ServiceController`.\n- Desktop composition: `lib/core/service.dart`; lifecycle/process ownership: `lib/core/desktop/lifecycle.dart`.\n- Desktop IPC/RPC: `lib/core/desktop/transport.dart` and `lib/core/desktop/rpc_client.dart`.\n- Desktop launch ownership: `lib/core/desktop/launcher.dart`; Windows and Linux Helper HTTP contract:\n  `lib/core/desktop/helper_client.dart` and `services/helper/`.\n- Flutter orchestration: `lib/providers/actions/core.dart` and `system.dart`; application exit:\n  `SystemExitCoordinator`; UI/event observation: `lib/manager/`.\n\nAn owner has more entry paths than the UI: provider calls, Quick Settings, notification actions, Always-on VPN, revoke\ncallbacks, application exit, and crash/disconnect recovery all reach it. Always-on startup is explicit through\n`VPN_START_REQUESTED` and revoke through `VPN_REVOKED`; service creation or destruction is never read as intent.\n\n## Verification\n\nFocused tests live at the narrowest layer, and the matching commands are in `.agents/commands.md`:\n\n- Desktop lifecycle/transport/RPC: `test/core/desktop/` plus `test/core/service_test.dart`.\n- Cross-language envelopes/events: `test/core/protocol_contract_test.dart` and `CGO_ENABLED=0 go test .`.\n- Provider/exit convergence: `test/providers/action_test.dart` and `test/providers/system_action_test.dart`.\n- Android Kotlin: compile each touched Gradle module with JDK 17.\n- Helper: `cargo fmt --check` and `cargo test`; the `windows-service` feature only builds on Windows.\n\nState host gaps explicitly. Always-on VPN, VPN permission, system revoke, named-pipe peer identity, polkit and systemd\non Linux, and Windows Service Control Manager behavior need their real platform even when portable tests pass.\n\n## Pitfalls\n\n- A `200` from Helper `/stop` means the Core is gone. When termination cannot be confirmed the Helper keeps the child\n  and answers `coreStopFailed`, and `/start` reports the same code instead of spawning a replacement. Keep that code\n  out of the Dart pre-spawn fallback set in `helper_client.dart`, or the direct launch races a Core the Helper still\n  owns.\n- The Helper owns executable integrity: Core SHA256 validation and the protocol stay identical across Flutter build\n  modes, so a debug build is not exempt.\n- `plugins/setup/` is a build harness, not a Dart API plugin, and its hooks can trigger Go or Rust compilation\n  indirectly through Flutter platform builds; a missing toolchain fails `flutter test`, not only `flutter build`.\n\n<!-- File: .agents/skills/localization/SKILL.md (Tokens: ~520 | Category: anthropic-skill) -->\n---\nname: localization\ndescription: Use when changing FlClash UI text, scanning for hardcoded Chinese strings, updating ARB localization, or fixing generated locale output in this repository.\n---\n\n# Localization\n\n## When To Use\n\nUse this for localization work in this repository, especially hardcoded UI text in `lib/`, ARB updates, missing translations, or generated `lib/l10n/` output that does not match source ARB values.\n\nDo not use this for README translation sync or manual edits to generated localization Dart.\n\n## Workflow\n\n1. Scan user-facing Dart text before opening many files. `flutter_intl` reads `arb/` and writes `lib/l10n/`:\n\n   ```bash\n   rg -n \"[\\\\p{Han}]\" lib -g '!lib/l10n/intl/**' -g '!lib/**/generated/**'\n   ```\n\n2. Inspect the smallest relevant call sites and nearby ARB keys.\n3. Add or update every source ARB:\n   - `arb/intl_en.arb`\n   - `arb/intl_zh_CN.arb`\n   - `arb/intl_ja.arb`\n   - `arb/intl_ru.arb`\n   - `arb/intl_fr.arb`\n   - `arb/intl_es.arb`\n   - `arb/intl_ko.arb`\n4. Replace inline strings with existing project accessors:\n   - Widgets with `BuildContext`: `context.appLocalizations.key` from `lib/common/context.dart`.\n   - Controllers/providers/non-widget code: `currentAppLocalizations.key` from `lib/common/app_localizations.dart`.\n5. Regenerate:\n\n   ```bash\n   dart run intl_utils:generate\n   ```\n\n6. Run `flutter analyze --no-fatal-infos` and `flutter test test/l10n test/lint/dynamic_message_key_test.dart`. The\n   lint test checks that every locale carries the same keys and that no `Intl.message` builds a key at runtime; labels\n   for enum values go through the switches in `lib/common/l10n_labels.dart`, as the ARB section of `.agents/rules.md`\n   explains.\n7. Re-run the Han-text scan for targeted strings.\n\n## Pitfalls\n\n- A locale that still shows English lacks the key or carries an English value in its source ARB; fix that ARB and\n  regenerate. Do not edit generated Dart.\n- Ignore `lib/l10n/intl/**` and `lib/**/generated/**` during text scans.\n\n<!-- File: .agents/skills/pre-commit-quality-review/SKILL.md (Tokens: ~1305 | Category: anthropic-skill) -->\n---\nname: pre-commit-quality-review\ndescription: Use when the user asks to commit, amend, or squash code, or requests a pre-commit structural quality review.\n---\n\n# Pre-commit Quality Review\n\nCheck that iterative edits converged into a clear, cohesive, maintainable implementation before they become a commit,\nso later code review is not spent on basic structure. `AGENTS.md` and `.agents/rules.md` apply throughout. This skill\ngrants no authority: commit, push, and history rewrites stay under the user's existing authorization, and silence is\nnot consent.\n\n## Triage\n\nMost commits stop here. Decide from the change summary, the diff, and the local context it needs; never start with a\nrepository-wide scan.\n\n- **Skip** formatting, mechanical renames, generated output, copy changes, and simple local fixes. They go through the\n  normal commit flow without extra confirmation. Say the review was skipped, never that it passed.\n- **Review** when a core implementation is replaced; when responsibilities, dependency direction, or public abstractions\n  change materially; when accumulated patches added branches, duplicate paths, or indirection; or when new code draws\n  responsibility boundaries. A small change can move a critical responsibility, so line count is not a trigger.\n- An explicit structural review request bypasses triage.\n\nThe snapshot under review is the intended commit compared with its baseline:\n\n| Operation | Baseline |\n|---|---|\n| Regular commit | `HEAD` |\n| Amend | Parent of the commit being replaced |\n| Squash | Version preceding the agreed range |\n| Initial commit | Empty tree |\n\nAsk when the range or parent is materially ambiguous instead of assuming the whole branch. Read staged versions of\npartially staged files; an unstaged fix is not part of the pending commit. Preserve unrelated changes and partial\nstaging.\n\n## Review the Final Structure\n\nScope the review to the responsibility boundaries this change touched. Read the complete final implementation of the\naffected units and compare before and after, not only the last patch. Report issues this change introduced or\nworsened; untouched historical debt is not a pre-commit requirement. Follow a call or dependency path only as far as a\nconcrete concern needs, and disclose missing essential context rather than claiming a complete pass.\n\nFocus on:\n\n- **Responsibilities and cohesion:** units mixing responsibilities that change independently; orchestration tangled\n  with implementation detail; responsibilities in layers that should not own them.\n- **Dependencies and abstractions:** reversed or circular dependencies, forwarding layers, abstractions that isolate\n  nothing; similar names or interfaces hiding different responsibilities.\n- **Complexity and readability:** nested conditions, repeated fallbacks, boolean parameter combinations, mutually\n  dependent helpers; names and decomposition that do not reduce reading effort.\n- **Duplication and consistency:** reimplemented existing capabilities, parallel implementations or duplicate state,\n  scattered ownership of error handling, cleanup, or state updates.\n- **Remnants:** obsolete branches, compatibility layers, unused parameters, commented-out code, comments the rewrite\n  invalidated; coupling that makes existing test boundaries hard to use.\n\nNot findings: length, a missing design pattern, naming taste, or anything without code evidence and a real\nmaintenance cost. Do not add cross-layer coupling to remove a few similar lines, rewrite stable code for uniformity,\nor demand abstractions for speculative needs.\n\nBusiness logic, UI behavior, performance, security, platform compatibility, and full functional review are out of\nscope. Report an incidentally found serious defect separately through the normal defect workflow, without hiding it or\nwidening this review.\n\n## Findings and the User's Decision\n\nA finding qualifies only when all three hold:\n\n- Before-and-after code and the necessary call relationships show this change introduced or worsened it.\n- The maintenance cost is concrete, such as updating one rule in two places or touching unrelated layers for one\n  responsibility. Clarity or elegance alone is not enough.\n- A minimal proposal fits existing constraints, with a benefit not offset by new coupling, indirection, or scope.\n\nWith no qualifying finding, state the reviewed scope and conclusion in a sentence or two and continue under the\nexisting commit authorization. Do not ask for confirmation because a review happened, and do not collect optional\nimprovements into a suggestion list.\n\nWith qualifying findings, finish the read-only review and independent checks first, merge findings that share a cause,\nand give for each: file, symbol, and location with how this change introduced it; the concrete maintenance cost; the\nsmallest proposal with its scope and any behavioral impact. Then **pause before creating the commit and ask whether to\noptimize first; do not edit or commit until the user answers.** Attribute the pause to this skill by file path so the\nuser recognizes their configured workflow, for example: \"The structural issues above support consolidating the\nduplicate state-update paths. Should I make that change before committing?\"\n\nExisting explicit authorization for these findings is enough to act without asking again. The user may accept all,\nsome, or none. Keep accepted findings and tradeoffs in the task context only, and do not re-ask because files were\nrestaged, a hash changed, or unrelated edits landed; reassess only when relevant code changes invalidate the tradeoff.\n\n## Resolve and Finish\n\nOnce authorized, make the smallest adjustments that resolve the accepted findings while preserving behavior and task\nscope; explain separately if either must change. Verify only the accepted findings and the direct effects of the\nadjustments, without another search for improvements. Raise a new finding only with evidence that meets the threshold\nand exceeds the existing authorization.\n\nBefore committing, recheck status and the actual staged diff so the pending snapshot matches what was reviewed, and\nreview only the delta of later edits. Run the checks `AGENTS.md` requires for the change: `flutter pub get`,\n`flutter analyze --no-fatal-infos`, and the covering `flutter test` suites listed in `.agents/commands.md`. Reuse\nresults that are still valid, rerun those a later edit invalidated, and never present results from an earlier snapshot\nas verification of the staged one. The `comment-density` and `commit-msg` hooks in `.agents/rules.md` still gate the\ncommit.\n\nReport the reviewed scope, completed improvements or accepted tradeoffs, check results, and any verification limits.\nClaim the commit is done only after it exists.\n\n<!-- File: .agents/skills/ui-work/SKILL.md (Tokens: ~2810 | Category: anthropic-skill) -->\n---\nname: ui-work\ndescription: 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.\n---\n\n# UI Work\n\n## When To Use\n\nUse this for user-facing Flutter UI changes in `lib/`, including widgets, screens, navigation surfaces, settings rows,\ndialogs, and interaction behavior.\n\n## Conventions\n\n- Reuse the patterns of nearby widgets, providers, notifiers, and helpers before adding an abstraction, and keep to the\n  look the rest of the app has: Material You from `material_ui` as the base, with Apple's interaction conventions\n  (continuous corners, sheets that snap between detents, a floating tab bar) built on Material widgets rather than\n  Cupertino ones. Do not introduce a visual system for one screen or rewrite a layout the request does not need.\n- `.agents/rules.md` is binding here: the lint set (`child:` last, `const`, final locals), the superellipse corner API,\n  and the widget-test facts under Testing Rules (`pumpAndSettle` never returns on `EditorView`, `dispose()` must not\n  await, auto-dispose providers need a container-level hold, platform decisions are passed in as parameters).\n- A component that reads no provider, model or app string goes in `lib/ui/`; one built for an app feature goes in\n  `lib/widgets/`. The UI Layer section of `.agents/architecture.md` says how a `lib/ui` component gets app state.\n- Settings screens are built from the `ConfigToggleItem` family in `lib/widgets/config_item.dart`; the Settings Rows\n  section of `.agents/architecture.md` says when a file-local helper or a named widget is warranted.\n- Every icon lives in `lib/ui/icons/`, never the Material `Icons` font: reuse or add a glyph in `AppGlyphs` and show it\n  with `GlyphIcon`, or with `AnimatedGlyph` where it fills on selection. Glyphs are outlined, except inside a filled\n  button, where they take `fill: 1`. The Icons section of `.agents/architecture.md` covers shape roles, the stroke, the\n  fill animation, and when to fill.\n- Text sizes and weights come from `AppTextStyle` in `lib/ui/typography.dart`, Apple's iOS Dynamic Type scale, never a\n  Material `TextTheme` slot, a bare `fontSize`, or a `fontWeight`; the Typography section of `.agents/rules.md` has the\n  rule.\n- Modals dim what they cover and never blur it: sheets with the Material 3 scrim (`ColorScheme.modalScrim`, `scrim` at\n  32%), alerts with `ColorScheme.alertScrim`, the lighter dim iOS 27 puts behind an alert. Every modal brings its own\n  scrim, including one opened from inside another, so do not make a barrier transparent to keep scrims from stacking.\n- A sheet's size belongs to its route, never to its content or call site: a bottom sheet rests at\n  `SheetProps.detents`, a side sheet is as tall as the window and as wide as `ModalSideSheetRoute` makes it from\n  the window width (`lib/ui/side_sheet.dart`, pinned by `test/widgets/side_sheet_width_test.dart`), and a form sheet\n  is a card `FormSheetRoute` caps in width and height (`lib/ui/form_sheet.dart`). Do not wrap a sheet's content in a\n  fixed width or height to resize one sheet.\n- A form with more than one value opens with `showFormSheet` and lays its values out in `FormRow`s, never as labeled\n  fields in a `CommonDialog`; a prompt for one value stays an alert. Forms in `.agents/architecture.md` has the rule.\n- A modal scrolls only once the window and keyboard leave it no room, and then through one scroll view: `CommonDialog`\n  caps its body at nothing but the space it is given, and a multi-line field in a dialog or sheet grows with its text\n  (`maxLines: null`) rather than scrolling inside a line count. With the keyboard up, a bottom sheet keeps what it\n  shows above the keyboard, rising past its tallest detent as far as a peek below the top (`_top` in\n  `lib/ui/snap_sheet.dart`).\n- The keyboard animates `MediaQuery.viewInsets` every frame. Read MediaQuery by aspect (`paddingOf`, `viewInsetsOf`)\n  rather than `MediaQuery.of`, and wrap the page of a new route type in `KeyboardInsetHold`; Keyboard Insets in\n  `.agents/architecture.md` has the reasons.\n- A page loads what it shows as it mounts, never once its route has settled; what waits out a transition is work\n  that repeats, such as a live feed's refreshes (`RouteMotionHoldMixin`), and the rows past the viewport\n  (`arrivalScrollCacheExtent`). Page Arrival in `.agents/architecture.md` has the rule.\n- A page that waits on its data shows nothing until all of it is ready (`NullStatusSwitcher.isLoading`), and a load\n  that can run long adds its illustration as `loadingStatus`, never a skeleton screen; Page Arrival in\n  `.agents/architecture.md` has the rule.\n- App bar buttons reach `CommonScaffold` as data (`primaryAction`, `iconActions`, `menuItems`) so it can fold them into\n  its overflow menu; App Bar Actions in `.agents/architecture.md` has the rule.\n- A row's secondary actions open from a context menu, never a trailing more button; a list whose rows have one\n  reorders in a sort mode, any other by a long press on the row. Row Actions And Sort Mode in `.agents/architecture.md`\n  has the rule.\n- Quick edit belongs only on a page that edits a whole list or section, or one isolated item; never on one that edits a\n  part of something other settings also write. Quick Edit in `.agents/architecture.md` has the rule.\n- A page or sheet opens on the navigator of the context it comes from, never on the root navigator unless Where A\n  Route Opens in `.agents/architecture.md` allows it.\n- User-facing text goes through ARB; use `localization` when text changes are non-trivial.\n- An asynchronous control keeps four things apart: authoritative provider/domain state; display-only state such as a\n  minimum progress duration; tap policy while work or a display hold is active; and failure/disposal cleanup, in\n  `finally` for animations and timers. `CoreStatusButton` and the proxies delay test action are the reference implementations,\n  described under User-Facing Core And Delay Feedback in `.agents/architecture.md`.\n\n## Corner Radii\n\nAll corner radii come from `lib/ui/shape.dart`: a token, a derived value, or a named off-scale value passed to its\nbuilders. Never write a bare radius literal at a call site.\n\nThe scale is picked by the component's **shortest side**. A radius that reads as a soft card at 64 dp tall\nreads as a pill at 24 and as a square at 400, so one radius everywhere is the wrong kind of consistency.\n\n| Token | Value | Shortest side | Use |\n| --- | --- | --- | --- |\n| `none` | 0 | - | square edges, and the flat side of a grouped run |\n| `xs` | 4 | inset blocks | tiles clipped inside an already rounded surface |\n| `sm` | 8 | up to 48 | chips, thumbs, swatches, small square tiles |\n| `md` | 16 | up to 64 | interactive chrome: inputs, menus, buttons, popups |\n| `lg` | 20 | 64 to 180 | mid-size cards: dashboard tiles, proxy node cards |\n| `xl` | 24 | full-bleed | anything spanning the whole width: list rows, grouped runs, group headers |\n| `xxl` | 28 | over 200 | sheets, full-screen containers |\n| `alert` | 34 | - | alerts, the corner of iOS 27's UIAlertController; on `AppCorner` only, passed to the builders |\n| `full` | 1000 | - | pills and circles: buttons, tracks, indicators, progress, avatars |\n\n- `AppCorner` holds the scale as `double`; `AppRadius` mirrors it as `BorderRadius` with `all`, `top`, and `vertical`\n  builders; `AppShape` mirrors it as `RoundedSuperellipseBorder`, `full` included, plus `circle`, `input`, and the\n  `all`/`top`/`vertical`/`of` builders. Use `AppShape.full` for pills, never `StadiumBorder`; the Corner Radius\n  section of `.agents/rules.md` explains why and lists the superellipse API per layer.\n- `AppCorner.fit(shortestSide)` snaps to the largest token at or under one third of the shortest side. Use it whenever\n  the size comes from a `LayoutBuilder` or scales with text.\n- `ThemeData.withAppShapes` in `lib/ui/shape.dart` already applies the scale to card, dialog, bottom sheet, snack\n  bar, chip, menu, input, FAB, navigation indicator, progress, and the filled, text, outlined, elevated, and icon\n  button themes. Do not restate those shapes at call sites, and\n  leave `InputDecoration.border` unset so inputs inherit `AppShape.input`.\n- Colors of surfaces follow `.agents/surfaces.md`: fills by `SurfaceLevel`, no structural borders, and a selected\n  item keeping its fill under a `SelectionCheck` or `SelectionBadge`, with no container role on a card or row.\n\n`xl` and `lg` exist because one token cannot serve both size classes. A full-bleed surface is wide and short, so its\ncorner must be larger than its height alone suggests or it stops reading as a card; everything full-bleed shares `xl`\n(the profiles card, the outer corners of a grouped run in `DecorationListItem`, the proxy\ngroup card in `lib/views/proxies/list.dart`) and does not use `fit()`. This is the one deliberate overshoot of the\none-third rule; the value stays under half a standard row so nothing clamps and every full-bleed surface renders the\nsame radius. `lg` is for mid-size cards that are not full-bleed: a proxy node card in the tab view at\n`ProxyCardType.compact` is only about 64 tall, which caps its radius at 21, so `xl` would break on a setting the user\ncan change at any time while `lg` clears both heights and the smallest dashboard tile.\n\nOutlined inputs use `AppInputBorder`, not `ShapedInputBorder` from `package:material_ui`. The package border subtracts\nthe floating label's notch as a flat band along the top edge, which takes the corner curve with it at any radius from 8\nup. `AppInputBorder` clips the notch region and paints the full superellipse through it, as Flutter's own\n`OutlineInputBorder` does by shortening the arc. It needs no `contentPadding` compensation.\n\nNested radii are derived, never tokens: concentric corners need `outer = inner + inset`, so name the inset and compute\nthe outer value, as `_cardRadius` in `lib/ui/popup.dart` does. Adding a token for one of these is how a scale grows without\nbound. A card that also stands on its own elsewhere keeps its token when nested: the proxy node cards inside a proxies\nlist group stay `lg` like those in the tab view, since deriving them from the group card's `xl` left them at 14, smaller\nthan every other card in the app.\n\nA component the scale does not fit keeps its own value instead of growing the scale: name it once in the file and pass\nit to the `all`/`top`/`vertical` builders, as `_itemShape` in `lib/ui/sidebar.dart` does with 10, between the\nchip-sized `sm` and the near-pill `md` on a 40 tall row. Reach for this only once the table and `fit()` are ruled out.\n\n## Verification\n\n- A behavior change gets a focused widget test covering rendering states, taps, scrolling, and empty/error states,\n  including the exception path of any async action. Run `flutter analyze` and `flutter test test/widgets/` plus the\n  tests of the touched view.\n- The scans in `test/lint/` fail on the common UI mistakes and run as part of `flutter test`: an `IconButton` whose\n  icon is an icon needs a `tooltip`; a field that constructs its own controller, notifier, or focus node is released in\n  the same file; a public declaration nothing references is dead. `.agents/rules.md` explains each rule and its\n  allowlist.\n\n## Pitfalls\n\n- Do not mutate provider/domain state merely to smooth a transition. Keep presentation holds local and let real errors\n  bypass them immediately.\n- Do not leave loading animations active when callbacks throw.\n- Do not manually edit generated localization or provider files.\n\n<!-- File: plugins/code_forge/analysis_options.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Code_forge Skill\n\nPath: `plugins/code_forge/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/code_forge/analysis_options.yaml)\n\n<!-- File: plugins/code_forge/pubspec.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Code_forge Skill\n\nPath: `plugins/code_forge/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/code_forge/pubspec.yaml)\n\n<!-- File: plugins/proxy/analysis_options.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Proxy Skill\n\nPath: `plugins/proxy/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/analysis_options.yaml)\n\n<!-- File: plugins/proxy/CHANGELOG.md (Tokens: ~300 | Category: anthropic-skill) -->\n# Proxy Skill\n\nPath: `plugins/proxy/CHANGELOG.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/CHANGELOG.md)\n\n<!-- File: plugins/proxy/pubspec.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Proxy Skill\n\nPath: `plugins/proxy/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/pubspec.yaml)\n\n<!-- File: plugins/proxy/windows/.vscode/settings.json (Tokens: ~300 | Category: anthropic-skill) -->\n# .vscode Skill\n\nPath: `plugins/proxy/windows/.vscode/settings.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/proxy/windows/.vscode/settings.json)\n\n<!-- File: plugins/rust_api/analysis_options.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Rust_api Skill\n\nPath: `plugins/rust_api/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/analysis_options.yaml)\n\n<!-- File: plugins/rust_api/CHANGELOG.md (Tokens: ~300 | Category: anthropic-skill) -->\n# Rust_api Skill\n\nPath: `plugins/rust_api/CHANGELOG.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/CHANGELOG.md)\n\n<!-- File: plugins/rust_api/CLAUDE.md (Tokens: ~300 | Category: anthropic-skill) -->\n# Rust_api Skill\n\nPath: `plugins/rust_api/CLAUDE.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/CLAUDE.md)\n\n<!-- File: plugins/rust_api/flutter_rust_bridge.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Rust_api Skill\n\nPath: `plugins/rust_api/flutter_rust_bridge.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/flutter_rust_bridge.yaml)\n\n<!-- File: plugins/rust_api/pubspec.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Rust_api Skill\n\nPath: `plugins/rust_api/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/pubspec.yaml)\n\n<!-- File: plugins/rust_api/rust/tests/fixtures/profile_config.json (Tokens: ~300 | Category: anthropic-skill) -->\n# Fixtures Skill\n\nPath: `plugins/rust_api/rust/tests/fixtures/profile_config.json`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/rust_api/rust/tests/fixtures/profile_config.json)\n\n<!-- File: plugins/setup/analysis_options.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Setup Skill\n\nPath: `plugins/setup/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/setup/analysis_options.yaml)\n\n<!-- File: plugins/setup/pubspec.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Setup Skill\n\nPath: `plugins/setup/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/setup/pubspec.yaml)\n\n<!-- File: plugins/setup/setup_hooks/analysis_options.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Setup_hooks Skill\n\nPath: `plugins/setup/setup_hooks/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/setup/setup_hooks/analysis_options.yaml)\n\n<!-- File: plugins/setup/setup_hooks/pubspec.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Setup_hooks Skill\n\nPath: `plugins/setup/setup_hooks/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/setup/setup_hooks/pubspec.yaml)\n\n<!-- File: plugins/tray/analysis_options.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Tray Skill\n\nPath: `plugins/tray/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/tray/analysis_options.yaml)\n\n<!-- File: plugins/tray/pubspec.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Tray Skill\n\nPath: `plugins/tray/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/tray/pubspec.yaml)\n\n<!-- File: plugins/wifi_ssid/analysis_options.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Wifi_ssid Skill\n\nPath: `plugins/wifi_ssid/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/wifi_ssid/analysis_options.yaml)\n\n<!-- File: plugins/wifi_ssid/pubspec.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Wifi_ssid Skill\n\nPath: `plugins/wifi_ssid/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/wifi_ssid/pubspec.yaml)\n\n<!-- File: plugins/window/analysis_options.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Window Skill\n\nPath: `plugins/window/analysis_options.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/window/analysis_options.yaml)\n\n<!-- File: plugins/window/pubspec.yaml (Tokens: ~300 | Category: anthropic-skill) -->\n# Window Skill\n\nPath: `plugins/window/pubspec.yaml`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/chen08209/FlClash/HEAD/plugins/window/pubspec.yaml)\n\n<!-- ... +10 more modular skills available via /api/skills/chen08209/FlClash?path={path} -->\n</yakaai_skills>"}