chore(agents,tool): rebuild agent config, lint gates, CI, and release tooling

Rework .agents/, Claude/Codex config, CI workflows, lint gates and
lint-rule tests, and the release/changelog tooling. The 5% comment
density gate stays diff-scoped inside git hooks, where GIT_DIR outranks
the -C the gate uses to locate the repository. The tray icon generator
writes multi-size ICO files from the SVG sources into the per-platform
tray asset directories. The custom code-review subagents are gone: the
built-in /code-review never dispatched them, and the subagent model now
lives in a user-level setting outside the repository.

The Makefile, build_config.yaml and CI hand the Core and Helper builds to
the setup build hook, so the buildkit launchers go, and setup.dart
packages AppImage and rpm on arm64 with flutter_distributor pinned to the
fork tag that normalizes packaging permissions.

Changelog: skip
This commit is contained in:
chen08209
2026-09-05 20:09:26 +08:00
parent 62addf738a
commit 96fcb9ab5e
60 changed files with 5482 additions and 1010 deletions
+5 -1
View File
@@ -4,7 +4,8 @@ Use the smallest durable surface that matches the job.
## Surfaces ## Surfaces
- `AGENTS.md`: auto-loaded repository entry point. Keep it small and reserve it for always-on rules, routing, and high-priority expectations. - `AGENTS.md`: repository entry point. Keep it small and reserve it for always-on rules, routing, and high-priority expectations.
- `CLAUDE.md`: the `@AGENTS.md` import and nothing that is not Claude-only. Claude Code loads `CLAUDE.md` into the system prompt at startup; the import makes `AGENTS.md` arrive with it. Without it, `AGENTS.md` reaches the session only as a tool result some agent chose to fetch, which is the weakest position in context and the first thing dropped when the conversation is summarized. Do not let rules accumulate in this file.
- `.agents/*.md`: human- and agent-readable reference docs linked from `AGENTS.md`. Use these for detailed project context, commands, architecture, and conventions. - `.agents/*.md`: human- and agent-readable reference docs linked from `AGENTS.md`. Use these for detailed project context, commands, architecture, and conventions.
- `.agents/skills/*/SKILL.md`: repo-scoped Codex skills. Use these for repeatable workflows that should be discoverable by name and description. - `.agents/skills/*/SKILL.md`: repo-scoped Codex skills. Use these for repeatable workflows that should be discoverable by name and description.
- `.codex/config.toml`: trusted project Codex settings such as MCP, hooks, sandbox, approval, or model defaults. - `.codex/config.toml`: trusted project Codex settings such as MCP, hooks, sandbox, approval, or model defaults.
@@ -17,6 +18,9 @@ Use the smallest durable surface that matches the job.
- Put detailed explanations in `.agents/*.md` and link them from `AGENTS.md`. - Put detailed explanations in `.agents/*.md` and link them from `AGENTS.md`.
- Put reusable task workflows in `.agents/skills/<skill-name>/SKILL.md`. - Put reusable task workflows in `.agents/skills/<skill-name>/SKILL.md`.
- Put mechanical enforcement in linters, tests, hooks, or `.codex/rules`; do not rely on prose when tooling can enforce the rule. - Put mechanical enforcement in linters, tests, hooks, or `.codex/rules`; do not rely on prose when tooling can enforce the rule.
- Split a rule into the part that can be counted and the part that cannot, and enforce them differently. A rule resting entirely on judgment gets decided at the keyboard, at the moment the exception looks most justified; the comment policy is the worked example, where "is this comment useful" stays prose and review while "are these comments too frequent" became a measured gate. Find the countable half before writing more prose.
- Restate a rule verbatim in every context that must obey it, and prefer a runnable check to a description of the rule. A gate an agent trips before reporting done outperforms a paragraph asking it to have been careful.
- State a gate's real force. Enforcement differs per tool, and a rule that claims uniform coverage it does not have is worse than one that names the gap.
- Keep user-specific preferences out of the repository. They belong in user-level Codex configuration or user-level skills. - Keep user-specific preferences out of the repository. They belong in user-level Codex configuration or user-level skills.
## Skill Authoring Rules ## Skill Authoring Rules
+403 -77
View File
@@ -16,7 +16,8 @@ Android lib mode:
Desktop core mode: Desktop core mode:
- Go core runs as a separate process with `CGO_ENABLED=0`. - Go core runs as a separate process with `CGO_ENABLED=0`.
- `rust_api` provides the native local-IPC primitives: a Unix domain socket on macOS/Linux and a named pipe on Windows. - `rust_api`'s `ipc` module provides the native local-IPC primitives: a Unix domain socket on macOS/Linux and a named pipe
on Windows.
Dart now owns the transport state, RPC correlation, process ownership, and lifecycle convergence above those primitives. Dart now owns the transport state, RPC correlation, process ownership, and lifecycle convergence above those primitives.
- `lib/core/service.dart` (`CoreService`) is the composition root. It wires the IPC transport, launcher selection, - `lib/core/service.dart` (`CoreService`) is the composition root. It wires the IPC transport, launcher selection,
lifecycle controller, RPC client, and crash-event bridge; it is no longer the whole desktop implementation by itself. lifecycle controller, RPC client, and crash-event bridge; it is no longer the whole desktop implementation by itself.
@@ -26,8 +27,8 @@ Desktop core mode:
applies a three-minute default method timeout, unwraps `CoreMethodResponse`, and fails all pending calls when transport applies a three-minute default method timeout, unwraps `CoreMethodResponse`, and fails all pending calls when transport
disconnects or closes. disconnects or closes.
- `lib/core/desktop/lifecycle.dart` serializes process intents and owns the authoritative desktop state machine. - `lib/core/desktop/lifecycle.dart` serializes process intents and owns the authoritative desktop state machine.
- `lib/core/desktop/launcher.dart` abstracts direct child-process and Windows Helper ownership through idempotent process - `lib/core/desktop/launcher.dart` abstracts direct child-process and privileged Helper ownership through idempotent process
leases. `lib/core/desktop/helper_client.dart` is the typed loopback HTTP client for the privileged Helper. leases. `lib/core/desktop/helper_client.dart` is the typed local HTTP client for the privileged Helper.
`lib/core/controller.dart` (`CoreController`) selects the implementation based on platform. `lib/core/interface.dart` defines the shared `CoreHandlerInterface`. `lib/core/controller.dart` (`CoreController`) selects the implementation based on platform. `lib/core/interface.dart` defines the shared `CoreHandlerInterface`.
@@ -39,6 +40,31 @@ Key Go core files:
- `core/lib.go`: CGO exports. - `core/lib.go`: CGO exports.
- `core/server.go`: desktop socket/named-pipe client and framed message forwarding. - `core/server.go`: desktop socket/named-pipe client and framed message forwarding.
## Listener Exposure
`_makeRealProfileTask` in `lib/common/task.dart` writes `mixed-port`, `allow-lan`, and `external-controller` into every
generated profile. With `allow-lan` off, mihomo's `genAddr` binds each listener to `127.0.0.1`; the external controller is
`ExternalControllerStatus.close` by default and never binds anywhere but loopback.
The loopback listener takes local connections without authentication by default: mandatory credentials would lock out
every external local consumer a proxy client exists for. The binding only guarantees traffic originates on the device;
it does not isolate programs already running there, so on Android any app holding `INTERNET` can use the proxy and learn
the outbound IP (#1934). The opt-in answer is the local authentication setting (`NetworkProps.localAuth`), which spans
four paths that must stay in sync: `_makeRealProfileTask` writes the credentials into `authentication` and force-clears
`skip-auth-prefixes` so a profile cannot silently exempt loopback; `UpdateParams.authentication` applies the same list
to a running core; `FlClashHttpOverrides` sends the credentials with the app's own proxied requests; and `sharedState`
withholds the Android VPN system proxy declaration because `ProxyInfo` cannot carry credentials (TUN still captures that
traffic). Desktop system proxy is deliberately not gated — dropping it would leak traffic direct, so the setting's
description tells users to supply credentials manually instead.
### Interface Name Modes
The same task also writes `interface-name` per `PatchClashConfig.interfaceNameMode`: `clear` (default) forces it empty so a
subscription's value cannot survive into the generated profile, `follow` leaves whatever the profile YAML already has, and
`custom` writes the user's `interfaceName`. mihomo ignores `interface-name` on Android whenever `dialer.DefaultSocketHook`
is installed (`core/lib.go` `installHooks`, vendored `dialer.go`), so `NetworkListView` in `lib/views/config/network.dart`
shows the mode picker and text field only on desktop.
## Lifecycle Ownership And Convergence ## Lifecycle Ownership And Convergence
### Shared Flutter Layer ### Shared Flutter Layer
@@ -87,8 +113,8 @@ caller, and uses a three-second watchdog as an emergency application-exit path.
- An unexpected disconnect or transport failure while running is converted to `DesktopCoreFailure`, the owned process is - An unexpected disconnect or transport failure while running is converted to `DesktopCoreFailure`, the owned process is
cleaned up, and `CoreService` emits a Core crash event for the normal UI recovery path. cleaned up, and `CoreService` emits a Core crash event for the normal UI recovery path.
Direct launch is used on macOS/Linux and as the Windows fallback when the privileged Helper is not ready. When the Helper Direct launch is used on macOS, inside an AppImage, and as the Windows/Linux fallback when the privileged Helper is not
is ready on Windows, the Helper owns the Core child and Dart owns it through a session-scoped lease. ready. When the Helper is ready, it owns the Core child and Dart owns it through a session-scoped lease.
### Android Service Lifecycle ### Android Service Lifecycle
@@ -157,6 +183,20 @@ Proxy delay testing follows the same failure-safe UI rule. `proxyDelayTest()` re
real result on success, and logs plus records `-1` on exceptions. `DelayTestButton` reverses its animation in `finally`, so real result on success, and logs plus records `-1` on exceptions. `DelayTestButton` reverses its animation in `finally`, so
an RPC failure cannot leave the control permanently spinning. an RPC failure cannot leave the control permanently spinning.
## Settings Rows
`lib/widgets/config_item.dart` holds the shared settings-row vocabulary: `ConfigToggleItem`, `ConfigOptionsItem`,
`ConfigTextItem`, and `ConfigListInputItem`. Each takes a `selector` (a `ProviderListenable`, normally
`someProvider.select(...)`) and an `onChanged(ref, value)` writer, and watches its own selector so changing one setting
rebuilds one row instead of the whole section. Titles and subtitles are `ConfigLabel` callbacks that receive
`AppLocalizations`, which keeps literal labels such as `IPv6` and localized labels in the same shape.
Build settings screens from these directly, or from a file-local helper that binds one provider once — see `_dnsToggle`
in `lib/views/config/dns.dart` and `_appSettingToggle` in `lib/views/application_setting.dart`. Declare a named
`ConsumerWidget` only when a row is genuinely reused across screens, as `lib/views/config/network.dart` rows are by
`lib/views/dashboard/widgets/quick_options.dart`. Rows with bespoke behaviour — a custom dialog, a derived value, or a
second provider write — stay hand-written rather than growing extra parameters on the shared items.
## State Management ## State Management
Provider files in `lib/providers/`: Provider files in `lib/providers/`:
@@ -164,10 +204,155 @@ Provider files in `lib/providers/`:
- `app.dart`: runtime/UI state, logs, traffic, delays, loading, navigation. - `app.dart`: runtime/UI state, logs, traffic, delays, loading, navigation.
- `config.dart`: persistent config providers, app settings, theme, VPN, proxy style. - `config.dart`: persistent config providers, app settings, theme, VPN, proxy style.
- `state.dart`: derived/computed providers, navigation, proxy, tray, color scheme. - `state.dart`: derived/computed providers, navigation, proxy, tray, color scheme.
Like `action.dart`, this is an entry point only: the providers live under
`lib/providers/state/` and are joined with `part` directives, so importing
`state.dart` still reaches all of them.
- `state/proxies.dart`: group and proxy lists, filter/sort, delay, selection.
- `state/navigation.dart`: navigation items, current page, dashboard, more tools.
- `state/system.dart`: tray, VPN params, access control, hot keys, shared state.
- `state/theme.dart`: dynamic color, color scheme, brightness.
- `state/profile.dart`: profiles, current profile, clash config, setup state.
- `state/overwrite.dart`: custom overwrite validity and the staged group/rule notifiers.
- `action.dart`: business logic notifiers, setup, backup, core lifecycle, proxy selection. - `action.dart`: business logic notifiers, setup, backup, core lifecycle, proxy selection.
- `core.dart`: `coreHandlerProvider`, the container-scoped handle on `CoreController`.
- `database.dart`: Drift database provider wrappers. - `database.dart`: Drift database provider wrappers.
`globalState` in `lib/state.dart` is a singleton holding app lifecycle, timers, theme, and start/stop state. Providers are generated into `lib/providers/generated/`. ### Reaching Singletons
`lib/common/` and `lib/core/` publish process-wide singletons (`coreController`,
`system`, `preferences`, `appPath`, `request`, and others). Code that already has a
`Ref` or a `WidgetRef` reads them through a provider instead, so a test can scope a
fake to one `ProviderContainer` rather than swapping a global and relying on a
tearDown to put it back.
`coreHandlerProvider` is the established case. Every call site under
`lib/providers/`, `lib/manager/` and `lib/views/` goes through it; notifiers and
`ConsumerState` classes that touch Core repeatedly hold it as
`CoreController get _core => ref.read(coreHandlerProvider)`.
Tests override it with `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))`,
which does not claim the singleton. `CoreController.test` does claim it, and is
only for tests that have not moved yet. Prefer the scoped override even when a
test passes either way: a test that claims the singleton makes the global and the
provider resolve to the same fake, so it cannot tell a provider read from a
leftover global read, and a half-migrated call site stays green.
One deliberate exception: `globalState` owns the `ProviderContainer`, so it cannot
itself live in one. Code without a `Ref` reaches providers through
`globalState.container.read(...)`. The remaining call sites are `lib/core/lib.dart`,
`lib/common/print.dart`, and the tray `read` callback in
`lib/providers/actions/system.dart` — all singletons or platform callbacks with no
`Ref` in scope.
`lib/models/profile.dart` no longer reaches Core. `Profile.saveFile` and
`Profile.update` take a `ValidateConfig` callback, and every caller passes
`(path) => _core.validateConfig(path)`, keeping the Core handle lazy so a profile
path that never validates never resolves the controller.
The UI layer must not reach a process-wide singleton directly.
`test/lint/ui_layer_singleton_test.dart` scans `lib/views`, `lib/widgets`,
`lib/pages` and `lib/features` for `globalState.container` and the bare
`coreController` global and fails the run on either. Widgets that need Core hold
`CoreController get _core => ref.read(coreHandlerProvider)`.
`globalState.measure` and `globalState.theme` stay global on purpose. Both are
context-derived caches assigned by `ThemeManager`, and tests already scope them by
assigning in the app builder (see `test/helpers/test_app.dart`); moving them into
providers would touch every layout call site without changing behaviour.
`globalState` in `lib/state.dart` is a singleton holding ambient app state — the
package info, the measure and theme, the container, and the start/stop flags —
plus `safeRun`/`loadingRun`. Startup orchestration is **not** on it: `init` and
`attach` live in `lib/bootstrap.dart`, above `lib/common`, because they drive the
window, the autostart entry, the tray and the permission prompts. Providers are
generated into `lib/providers/generated/`.
The root navigator key lives in `lib/common/navigator.dart` as `rootNavigatorKey`;
`globalState.navigatorKey` is a getter onto it. `lib/common/dialog.dart` reaches
the key directly, so the dialog helpers no longer import `lib/state.dart`.
### Platform Layering
`lib/common/common.dart` deliberately does not export `tray.dart`, `window.dart`,
`launch.dart`, `system_dns.dart`, or `permission.dart`. Those five modules import
`tray_manager`, `window_manager`, `launch_at_startup`, and `screen_retriever`;
exporting them put those packages in the compile graph of all 132 files that
import the barrel for a string helper. Import the specific module instead.
`test/lint/platform_layering_test.dart` enforces four rules. Three are local: the
barrel never re-exports one of those five modules, nothing under `lib/common`,
`lib/enum` or `lib/models` other than those five imports a desktop platform
package, and `lib/common` never imports the `lib/manager/manager.dart` barrel
(import the single manager needed, as `common/context.dart` does with
`manager/status_manager.dart`). The fourth walks the barrel's whole transitive
closure and fails if *any* file in it imports one of those packages. That one is
the real invariant — the local rules only stop the shortest path, and every leak
found so far arrived through a longer one.
Four consequences are already in the tree:
- `System.back` and `System.exit` no longer touch `window`; the window half of
both lives in `SystemAction`.
- `KeyboardModifier.toHotKeyModifier()` moved from `lib/enum/enum.dart` to
`lib/manager/hotkey_manager.dart`, its only consumer.
- Startup orchestration moved off `GlobalState` into `lib/bootstrap.dart`.
`common/num.dart`, `common/print.dart` and `common/request.dart` import
`state.dart` for `theme`, `container` and `packageInfo`/`ua`, so anything
`GlobalState` reaches lands in the barrel's closure; the ambient state it now
holds reaches nothing platform-specific.
- `SystemAction` talks to `WindowPort` and `TrayPort` from
`lib/common/app_ports.dart` instead of importing `common/window.dart` and
`common/tray.dart`. `lib/bootstrap.dart` binds `windowPort` and `trayPort` to
the real implementations; both stay null in tests, where every call through
them is a no-op. A test that needs the real tray assigns `trayPort` itself, as
`test/common/tray_menu_test.dart` does.
Narrow the barrel imports too: `lib/providers/providers.dart` re-exports
`action.dart`, so importing the providers barrel from `lib/common` or from
`providers/app.dart` reaches the whole action layer. Those three now import
`providers/state.dart` and `providers/config.dart` directly.
The same shape appeared twice more, without a platform package involved: a data
type in a lower layer holding the widget that renders it, which drags the whole
view tree into the barrel's closure.
- `lib/common/navigation.dart` was a route table building view widgets. It is
now `lib/views/navigation.dart` implementing `NavigationPort`, which
`providers/state/navigation.dart` reads through and `bootstrap.init` binds.
Unbound it yields no items, so a test that renders navigation assigns
`navigationPort` itself, as `test/pages/home_test.dart` does.
- `DashboardWidget` carried a `GridItem` per value, so `lib/enum/enum.dart`
imported the dashboard cards — and `lib/widgets/widgets.dart` with them. The
enum is persisted in the app settings, so it is back to plain data; the
mapping lives in `lib/views/dashboard/widget_registry.dart`, the reverse
lookup relying on the branches returning canonical consts.
Together those took the closure from 258 files to 187, with nothing under
`lib/views` left in it. `test/lint/platform_layering_test.dart` pins that
directly: the barrel's closure must contain no `lib/views` file. Data the
provider layer needs from the UI layer goes through a port in
`lib/common/app_ports.dart` rather than an import in the other direction.
### High-Frequency Buffers
`logsProvider`, `requestsProvider` and `trafficsProvider` hold a `FixedList`
(`lib/common/fixed.dart`), which trades a normal copy-on-write for a shared
buffer tagged with a generation counter:
- `append` mutates the buffer in place and returns a new wrapper one generation
ahead. That is what providers publish, so `updateShouldNotify` still fires.
- `list` returns an immutable copy, cached until the next mutation. It must stay
eager: an older wrapper shares the buffer, so its contents move on. Anything
that needs a stable view has to read `list` at the moment it is notified, not
hold the wrapper and read later.
- Consumers that only need to know *that* the buffer changed watch `revision`,
not `list` — selecting on the list snapshots and deep-compares the whole
buffer on every arrival, which is what this design exists to avoid. See
`lib/views/logs.dart` for the pattern: watch the generation, snapshot inside
the throttled callback.
`add`/`clear` mutate in place without advancing the generation; use them only on
a buffer you own outright (seeding, resets, tests), never on published state.
## Database ## Database
@@ -188,16 +373,31 @@ Generated Drift output lives in `lib/database/generated/database.g.dart`. After
## Manager Stack ## Manager Stack
Managers are nested `InheritedWidget`/`StatefulWidget` components in `lib/application.dart`: Managers are nested `InheritedWidget`/`StatefulWidget` components built by `buildManagerStack()` in `lib/application.dart`:
```text ```text
AppEnvManager > StatusManager > ThemeManager AppEnvManager > StatusManager > ThemeManager
> [Desktop: WindowManager > TrayManager > HotKeyManager > ProxyManager] > [Desktop: WindowManager > TrayManager > HotKeyManager > ProxyManager]
> ConnectivityManager > CoreManager > AppStateManager [Mobile: AndroidManager > TileManager]
> [Mobile: AndroidManager > VpnManager | Desktop: WindowHeaderContainer] > AppStateManager > CoreManager > ConnectivityManager
> [Desktop: WindowHeaderContainer] [Mobile: VpnManager]
> app content
``` ```
Each manager in `lib/manager/` handles a specific platform concern. Desktop-only managers are conditionally inserted. Each manager in `lib/manager/` handles a specific platform concern. The
platform slots are exclusive: no desktop manager appears on mobile and no mobile
manager appears on desktop.
The order is an ownership contract, not a layout detail. `ConnectivityManager`
sits below `CoreManager` because its `onConnectivityChanged` callback reads
Core-backed state, so Core must already be mounted when it fires. `StatusManager`
and `ThemeManager` sit above the platform managers so a platform manager can
surface a message or read the theme.
`buildManagerStack()` is a pure function of `isDesktop`, the connectivity
callback, and the app content, so `test/application_test.dart` asserts the whole
order by constructing the stack without mounting it. Changing the nesting means
updating both this diagram and that test.
## Core Controller and Actions ## Core Controller and Actions
@@ -218,6 +418,17 @@ directives, so consumers continue to import the same public API:
- `ProxiesAction`: group management and proxy selection. - `ProxiesAction`: group management and proxy selection.
- `ProfilesAction`: profile CRUD, auto-update, import. - `ProfilesAction`: profile CRUD, auto-update, import.
- `GeoResourceAction`: geo resource updates and URL configuration. - `GeoResourceAction`: geo resource updates and URL configuration.
- `UpdatingAction`: stale sweep over `UpdatingKeys`.
`UpdatingKeys` in `lib/providers/app.dart` owns every per-entity updating flag; `isUpdatingProvider(key)` is the
read-only view widgets watch. Callers pair `start(key, scope: ...)` with `stop(key, operation)` using the returned
token, so overlapping operations on one key are reference counted and a late `stop` from a superseded operation
cannot clear a newer one. Keys started with `UpdatingScope.core` depend on the Core to make progress, so
`UpdatingKeys` discards them itself when `coreStatusProvider` leaves `connected` — that is the state's own
invariant, not a policy, and it must stay inside the notifier where no warm-up ordering can miss it.
`UpdatingScope.local` keys (profile updates run entirely in Dart) survive a Core restart. `UpdatingAction` holds
only the policy half: the periodic sweep that discards a key stuck past `updatingStaleTimeout`. Do not move the
timeout back into `UpdatingKeys`, and do not widen the disconnect reset to every scope.
## Platform Managers ## Platform Managers
@@ -244,67 +455,68 @@ Shared:
## Build System ## Build System
`setup.dart` is the release build orchestrator: `setup.dart` is the release build orchestrator: it writes `env.json` (`APP_ENV`), activates `flutter_distributor` from
the `chen08209/flutter_distributor` fork pinned to a `v<version>-flclash.<n>` tag (cut a new tag there and bump
`--git-ref` when the fork changes), and leaves the Core artifacts to the build hook.
1. Writes `env.json` (`APP_ENV`). The Go core and the Rust helper are built by a Dart build hook. `plugins/setup/hook/build.dart` only constructs
2. Activates `flutter_distributor` for packaging. `CoreBuilder`, a `package:hooks` `Builder` in `plugins/setup/setup_hooks/`, the same shape `rust_api` uses. Flutter
3. Relies on the platform build hook to build the required Core artifacts before runs it for every platform build and for `flutter test`, once per target architecture. `CoreBuilder` turns the hook
the native application is linked. input into a `BuildRequest` (repository root, `Target`, Android toolchain) and hands it to `buildPlatform`. It lives in
`setup_hooks` so `dart test` there can cover it; a test run of `plugins/setup` itself would execute the hook. Failures
reach the runner as `BuildError` (Go or Cargo failed, no Core for the architecture) or `InfraError` (a toolchain could
not start, Flutter passed no NDK compiler, the package is not at `plugins/setup`).
Go core building is handled by `build_tool`, a standalone Dart CLI in `plugins/setup/buildkit/build_tool/`. What the hook protocol forces:
Platform build hooks inside `flutter build` trigger `build_tool` automatically: - Android derives the per-ABI clang wrapper from the C compiler and `targetNdkApi` (the app's `minSdk`) Flutter
passes; `hooks_runner` filters the environment, so `ANDROID_NDK` is not read.
- `PATH` arrives unextended, so `runCommand` appends the Homebrew, Go and rustup locations that Xcode's and Gradle's
stripped `PATH` hides.
- The hook does not know the build mode: the protocol carries none and `linkingEnabled` only says whether link hooks
run, so nothing in the hook branches on debug versus release. It cannot tell `flutter test` from a build either; the
`build_assets: false` user-define in the root `pubspec.yaml` is the one switch (see `.agents/commands.md`), and
`setup.dart` refuses to package while it is set.
- The hook declares the files it read but the *directories* it wrote: `hooks_runner` hashes a file by content and a
directory by child names, so `libclash/macos/` catches a deleted artifact without MD5-ing a 55 MB Core on every
build. A rebuild touches those directories after the runner's cutoff, so the next build runs the hook once more and
setup's cache answers it; that extra run is expected.
- The runner discards hook output on success, even under `-v`, so the hook mirrors it into
`.dart_tool/setup_build_cache/hook.log` (rotated at 512 KB), one `===` header per invocation.
- macOS: podspec script phase, `build_pod.sh`, `build_tool macos`. Platform projects copy the artifacts out of `libclash/`; application code must not import `plugins/setup`:
- Linux: CMake include, `buildkit/cmake/buildkit.cmake`, `build_tool linux`.
- Windows: CMake include, `buildkit/cmake/buildkit.cmake`, `build_tool windows`. CMake forwards the active configuration through `BUILDKIT_CONFIGURATION`.
- Android: Gradle include, `buildkit/gradle/plugin.gradle`, `build_tool android`.
### Setup Build Harness Plugin - Android: the Go core is built `c-shared`, and `libclash.so` with its headers lands in the `:core` module (see Android
Native Task Ordering).
- macOS: a standalone `FlClashCore`. `Release.xcconfig` pins release and profile `ARCHS` to the host because
flutter_tools otherwise builds a universal binary and every artifact ships one slice; the hook skips a non-host slice
for the same reason. The `Stage Core` phase copies the Core after the hook may have rewritten it and fails when it is
missing or lacks a slice for `ARCHS`, so a skipped hook cannot stage a stale Core silently.
- Linux and Windows: `FlClashCore`, the Rust `FlClashHelperService`, and a `manifest.json` holding `coreSha256`; the
Core builds first because the Helper embeds its hash. The CMake `install` rules copy them, and the Windows bundle
places `manifest.json` beside the executable. A Helper running from a Debug build keeps its exe open and the install
fails behind an opaque `MSB3073`, so `windows/CMakeLists.txt` stops it from an `install(CODE)` step for the `Debug`
configuration only, and only when the staged Helper differs from the installed one; that step is where the
configuration is known, and a Release install never stops a registered service. The step matches the process by
executable path rather than image name, so a Helper registered from an installed release keeps running.
`plugins/setup/` is a build-time Flutter plugin, not a runtime Dart or FFI API. Its plugin shape exists so Flutter's native Setup keeps its own cache under `.dart_tool/setup_build_cache/v1/` because it builds a Go core and, on Windows and
build graphs can run the Go/Rust build harness before platform consumers need the generated artifacts. Application code Linux, a Rust helper:
must not import or call it.
Responsibilities are deliberately split: - Go fingerprints cover the target-specific `go list -deps` inputs in `core/` and `Clash.Meta`, module files, the
effective build configuration, `setup_hooks` sources, target flags, the Go toolchain and the Android clang version.
- CocoaPods, Gradle, and CMake hooks schedule a lightweight check on every native build. They do not decide which Go or Helper fingerprints cover its Rust sources and manifests, Cargo/Rust toolchains and flags, and the expected Core
Rust files are stale. SHA256.
- `buildkit/build_tool/` owns target resolution, input fingerprinting, compilation, output copying, and cache validation. - A hit requires the fingerprint and every recorded output's path, size and modification state to match, and skips
- `core/` and `services/helper/` remain source owners; `libclash/` and Android `jniLibs`/header directories are generated compilation and copying.
output locations. - Records are written only after a successful build, under per-target locks. Compilation happens in a staging
- `setup.dart` remains the release/package orchestrator and does not pre-build directory and moves into place on success, so a failed build never destroys the previous artifacts.
platform artifacts or use `dart-define` for Core integrity data. The Windows - `flutter clean` removes `.dart_tool` and forces one full rebuild; deleting `.dart_tool/setup_build_cache/` does the
build tool writes the runtime `manifest.json` beside the Core output, and the same without a clean.
Windows bundle copies it beside the application executable.
Platform outputs remain explicit:
- Android builds the Go core as `c-shared`, then copies `libclash.so` and generated headers into the `:core` Android module.
- macOS and Linux build a standalone `FlClashCore` process used by the desktop socket integration.
- Windows builds `FlClashCore.exe`, the Rust `FlClashHelperService.exe` privileged helper, and a
`manifest.json` containing only `coreSha256`.
The hooks follow rust_api/Cargokit's phony-output scheduling pattern, but setup uses its own cache because it builds both a
Go core and, on Windows, a separate Rust helper. Per-target records live under `.dart_tool/setup_build_cache/v1/`:
- Go fingerprints cover the target-specific `go list -deps` inputs inside `core/` and `Clash.Meta`, module files, effective
build configuration, build-tool sources, target flags, Go environment/toolchain, and Android NDK compiler details.
- Windows helper fingerprints cover its Rust sources and manifests, Cargo/Rust
toolchains and flags, and the expected Core SHA256.
- A cache hit requires the fingerprint and every recorded output's path, size, and modification state to match. It exits
silently without Go/Cargo compilation, output copying, or Windows `taskkill`.
- Cache records are written only after a successful build and protected by per-target process/file locks. Missing outputs,
changed inputs, cache-schema changes, or `--force` rebuild only the affected target.
- `flutter clean` removes `.dart_tool`, so the next native build performs one full core rebuild. Manual builds can bypass
the cache with `make core-<platform> FORCE=1`.
This differs from `rust_api`: rust_api is a runtime Flutter Rust Bridge integration whose Cargokit hooks produce its native
FFI library, while setup is only the build and packaging bridge for FlClash's external core artifacts.
Windows helper integrity/version check: Windows helper integrity/version check:
- The build tool constructs the Core first, calculates its SHA256, and always - `setup_hooks` constructs the Core first, calculates its SHA256, and always
builds the Rust Helper with release hardening and that expected hash. builds the Rust Helper with release hardening and that expected hash.
- Flutter reads the Core SHA256 from the bundled `manifest.json` and sends it with `/ping`. Debug, Profile, and Release - Flutter reads the Core SHA256 from the bundled `manifest.json` and sends it with `/ping`. Debug, Profile, and Release
builds use the same Helper protocol and may use TUN through the same flow. builds use the same Helper protocol and may use TUN through the same flow.
@@ -329,31 +541,110 @@ Windows helper integrity/version check:
- `/stop` requires the same session ID. A missing process returns `notRunning`; a different owner returns - `/stop` requires the same session ID. A missing process returns `notRunning`; a different owner returns
`sessionMismatch` without terminating that process. Session IDs are ownership tokens for lifecycle safety, not a claim `sessionMismatch` without terminating that process. Session IDs are ownership tokens for lifecycle safety, not a claim
that the loopback HTTP endpoints are authenticated. that the loopback HTTP endpoints are authenticated.
- The Core must not outlive the Helper. On Linux the systemd unit's cgroup and `Restart=on-failure` cover that; on
Windows the Helper puts the Core in a Job Object with `KILL_ON_JOB_CLOSE`, so the kernel kills the Core when the Helper
process ends for any reason, and the `install` subcommand configures SCM failure actions (three restarts, 5 s apart,
reset after 24 h). Stopping a Core sends SIGTERM first on Linux, because only a shutdown removes the policy routes
sing-tun installed; the Core runs the same teardown itself when its IPC host disconnects or it receives a termination
signal. `HelperClient.stopTimeout` must stay above the Helper's graceful-plus-kill budget.
- Never take `MANAGED_CORE` or `LOGS` with `lock().unwrap()`. The Helper is a long-lived service running as SYSTEM, so a
single panic while a lock is held would poison it and turn every later request into another panic — the service stays
dead until Windows restarts it. `lock_surviving_poison` recovers the guard through `PoisonError::into_inner` instead.
`hub.rs` uses it at every lock site, tests included, and two tests in that file pin the behaviour.
Build configuration defaults live in `build_tool/lib/src/options.dart` and can be overridden via a root `build_config.yaml`. Build configuration defaults live in `plugins/setup/setup_hooks/lib/src/options.dart` and can be overridden via the root
`build_config.yaml`.
Architecture detection is automatic. The `--description` flag passed to `flutter_distributor` adds arch suffixes to artifact names, such as `FlClash-0.8.93-macos-arm64.dmg`. Architecture detection is automatic. The `--description` flag passed to `flutter_distributor` adds arch suffixes to artifact names, such as `FlClash-0.8.93-macos-arm64.dmg`.
#### Android Native Task Ordering
`:core` consumes `libclash.so` and its headers as files the setup build hook writes into `android/core/src/main`, not
as an asset the hook hands back, so nothing in Gradle's model links the two. AGP's configure fingerprint tracks only
`CMakeLists.txt` and its own generated files, which makes a wrong ordering *sticky*: a configure that ran before the
hook stays cached and every later build reuses it. `CMakeLists.txt` therefore links `clash` unconditionally, and
`android/core/build.gradle.kts` fails before any native task runs when those files are absent — both push the failure
out of the configure step, where it would be cached, and into a place that re-evaluates every build. Three details
there are easy to get wrong:
- `defaultConfig.ndk.abiFilters` is derived from the `target-platform` property Flutter passes to Gradle, mapped
through the same ABI table as `Target.forPlatform('android')` in `setup_hooks`; keep the two tables equal. The hook
builds a Core only for the platforms of the current build, so a `:core` ABI outside that set has no `libclash.so` to
link, and without any filter AGP would configure the NDK's full default ABI set.
- Match task names by **prefix**. AGP puts the ABI in the names of the tasks that do the work
(`configureCMakeDebug[arm64-v8a]`). The bare `configureCMakeDebug`, `buildCMakeDebug`, and `externalNativeBuildDebug`
that an exact-name match catches are grouping tasks that never enter an app build's execution graph.
- Use `mustRunAfter`, not `dependsOn`. `:core` declares only `debug` and `release` variants, so a **profile** app
consumes `:core`'s `debug` variant; no `:core` variant name maps onto the right `:app` task, and `dependsOn` would
drag a second, wrong-mode Flutter build into the graph.
A standalone `./gradlew :core:assembleDebug` stays legal once the hook has run at least once; it only warns that the
artifacts under `src/main` are whatever the last hook run left behind, which is the one failure mode this guard cannot
detect. Verify a change here with `./gradlew ":core:buildCMakeDebug[arm64-v8a]" :app:compileFlutterBuildDebug
--dry-run`: the Flutter task must be listed first even though the CMake task was requested first.
## Local Plugins ## Local Plugins
- `setup`: build-time harness for Go core artifacts and the Windows Rust helper; no runtime Dart API. - `setup`: build-time harness for Go core artifacts and the Rust helper, driven by a Dart build hook; no runtime Dart API.
- `proxy`: system proxy configuration. - `proxy`: system proxy configuration.
- `rust_api`: runtime Flutter Rust Bridge FFI plugin built through Cargokit. - `rust_api`: runtime Flutter Rust Bridge FFI package built through Native Assets. See below.
- `tray_manager`: system tray fork/customization. - `tray`: system tray for Linux, macOS and Windows. Written for FlClash; replaced the `tray_manager` fork.
- `wifi_ssid`: Wi-Fi SSID detection. - `wifi_ssid`: Wi-Fi SSID detection.
- `window_ext`: window extensions.
- `flutter_distributor`: app packaging/distribution. - `flutter_distributor`: app packaging/distribution.
## rust_api Crate Layout
`plugins/rust_api` has no platform folders. `hook/build.dart` is a Dart build hook: Flutter runs it for every platform
build and for `flutter test`, and `flutter_rust_bridge_hooks` (over `native_toolchain_rust`) compiles the crate with
Cargo and registers `librust_api` as a code asset that Flutter bundles and signs. The build runs
`rustup run <channel>`, so `rust/rust-toolchain.toml` must pin an exact channel and list every target the project ships;
the hook refuses `stable` and a target missing from that list. `rustup show` installs the pinned toolchain and those
targets on first use.
`plugins/rust_api/rust/src/` separates the bridge boundary from the code behind it:
- `api/` is the only input flutter_rust_bridge parses (`rust_input: crate::api`). Every function there is a thin
delegation, so the generated bindings stay identical on every platform.
- `ipc/` implements the desktop socket server: `frame` (length-prefixed framing and the write backoff), `queue` (the
bounded send queue), `platform` (socket cleanup, Windows peer credentials and the non-blocking pipe reader), and
`server` (lifecycle, accept loop, and the `RUNNING`/`STATE` globals).
- `script/` runs profile override scripts on QuickJS through `rquickjs`.
- `hotkey/` registers desktop global shortcuts through `global-hotkey`: `keys` maps Flutter USB HID usages to key
codes, `owner` runs every registration on the thread the platform binds it to (a dedicated message-loop thread on
Windows, the main dispatch queue on macOS, in place on Linux), and `service` owns the registry and forwards presses
to Dart. Linux is X11 only; a Wayland session without XWayland gets an error rather than a silent no-op.
What a platform does not use, it does not compile. `interprocess` and `global-hotkey` are declared under
`cfg(not(target_os = "android"))`, and `ipc/mod.rs` and `hotkey/mod.rs` swap in their `unsupported.rs` there, because
Android loads the Core in-process and has no global shortcuts. Adding a capability follows the same shape: implement it in its own module, gate the
dependency by target, and keep the `api/` entry point unconditional so one set of bindings still serves every platform.
`RustLib.init()` runs on every platform now, not only desktop — the script engine is shared.
## Profile Script Engine
`lib/common/javascript.dart` sends the profile as JSON to `evaluate_script`, which runs `main(config)` on QuickJS and
returns the JSON the script produced. Nothing about the script runs in Dart.
- QuickJS is compiled from source for the target being built, which is what removed the prebuilt `quickjs-c-bridge`
binaries: `flutter_js` shipped x64 Windows and desktop-only libraries, so Windows ARM64 could not start (#2361).
- `rquickjs` carries pre-generated bindings for every target this project builds except the Android ones, so Android
builds enable its `bindgen` feature. That needs the NDK's own libclang and sysroot: `native_toolchain_rust` exports
the sysroot through `BINDGEN_EXTRA_CLANG_ARGS_<target>`, and `hook/build.dart` adds `LIBCLANG_PATH` from the NDK
toolchain Flutter hands the hook, because bindgen otherwise loads whatever libclang the host has, or none.
- Evaluation is bounded: a 10-second interrupt deadline and a memory ceiling, because a script that never returns would
otherwise hold the profile forever. `console` is installed before the script runs, since scripts written for other
clients log as they work.
- `rust/tests/fixtures/profile_script.js` is the compatibility regression: an overwrite written for the suite that
performs the transform real ones perform, so it exercises `Map`/`Set`, spread, destructuring, optional chaining,
nullish coalescing, `Object.fromEntries`, named capture groups and lookbehind in one pass. Keep it first-party and
free of external URLs — vendoring somebody's published script here carries their attribution and their links.
## Rust Helper Service ## Rust Helper Service
`services/helper/` is a Windows-only privileged helper for starting the core as admin and managing TUN. It is built with: `services/helper/` is the privileged helper that starts the core elevated so TUN works. It ships on Windows and Linux
and is built by the setup build hook alongside the Core, which always compiles the Helper in Rust release mode
```bash after calculating the SHA256 of the Core produced for the active Flutter configuration.
make core-windows
```
The build tool always compiles the Helper in Rust release mode after calculating
the SHA256 of the Core produced for the active Flutter configuration.
The helper owns its Windows Service Control Manager lifecycle through two elevated commands: The helper owns its Windows Service Control Manager lifecycle through two elevated commands:
@@ -365,6 +656,38 @@ The helper owns its Windows Service Control Manager lifecycle through two elevat
The Dart layer only launches the helper's `install` command through `ShellExecuteW`; it does not compose `sc.exe`, The Dart layer only launches the helper's `install` command through `ShellExecuteW`; it does not compose `sc.exe`,
`taskkill`, or `cmd.exe` command lines. `taskkill`, or `cmd.exe` command lines.
Linux takes the same shape with systemd in place of the Service Control Manager, and the same install timing: nothing
is registered at package install, and `Linux.registerService` asks for elevation only when TUN authorization needs it.
- `FlClashHelperService install`, run through `pkexec` so polkit raises the system prompt, writes
`/etc/systemd/system/flclash-helper.service` for the current executable path and enables and restarts it. It reads
`PKEXEC_UID`/`SUDO_UID` to learn who asked, and refuses to install without one — there would be no account to grant
the socket to. It also refuses a Helper whose binary or directory is not root-owned and non-writable (a unit runs it
as root at every boot, so an unpacked bundle would be a standing escalation), and refuses to replace a unit already
installed for a different UID rather than restart the service out from under that account.
- That ownership check is why the `flutter_distributor` fork normalizes the packaging tree to 0755/0644 before
`dpkg-deb`, `rpmbuild` and `appimagetool` run: they record modes verbatim, and Ubuntu's per-user default umask
of 002 would otherwise ship `/opt/FlClash` as 0775, which the installer rejects as group-writable.
- The rpm spec sets `debug_package` and `__os_install_post` to nil for the same reason: rpmbuild's find-debuginfo and
brp-strip rewrite `FlClashCore`, and a Core whose SHA256 no longer matches the Helper's embedded value is refused at
`/start`, which silently degrades every launch to the direct Core.
- `FlClashHelperService uninstall` disables the unit, removes it and reloads systemd.
- The unit carries `Group=` (the owner's primary GID), `RuntimeDirectory=flclash`, the owner's UID/GID in
`FLCLASH_HELPER_OWNER_UID`/`_GID`, a double-quoted `ExecStart=` with `%` escaped, and `Restart=on-failure` under a
start limit so a broken unit ends up failed instead of restarting forever. The helper serves
`/run/flclash/helper.sock` at mode `0660`, additionally drops any connection whose `SO_PEERCRED` UID is not the
owner's, logs and retries an `accept` failure instead of letting hyper end the server, and handles SIGTERM so
`systemctl stop` still runs its own Core teardown.
- `/start` additionally requires the Core address to be a socket owned by the owner UID before spawning, since the
Core connects to it as root.
- The Core is spawned with the owner's real UID and an effective UID of 0, which is what a setuid Core would have had.
It is the signal `core/ownership_unix.go` uses to hand the files it created back to the user; without it a root
service would leave a root-owned config tree in the owner's home.
- An AppImage has neither a stable executable path nor a writable Core, and its FUSE mount is `nosuid`, so
`system.isAppImage` reports TUN authorization as unavailable instead of prompting for a password that cannot help.
- A Linux host without systemd (`/run/systemd/system` absent) has no Helper: `system.hasHelperService` is false there,
readiness is the `stat` check, and `pkexec` sets the setuid bit on the bundled Core as before.
In every Flutter build mode `/start` opens the fixed Core executable beside the Helper without write/delete sharing, In every Flutter build mode `/start` opens the fixed Core executable beside the Helper without write/delete sharing,
validates it against the SHA256 embedded only in the Helper, and keeps that handle open through process creation. validates it against the SHA256 embedded only in the Helper, and keeps that handle open through process creation.
`/ping` only compares the requested `coreSha256` with the Helper's embedded value and checks the fixed Core path exists; `/ping` only compares the requested `coreSha256` with the Helper's embedded value and checks the fixed Core path exists;
@@ -378,6 +701,9 @@ it never hashes the Core. Protocol version 6 uses 32-character lowercase-hex ses
- `POST /stop` validates `{sessionId}` and only stops the matching managed Core. A session mismatch is HTTP 409. - `POST /stop` validates `{sessionId}` and only stops the matching managed Core. A session mismatch is HTTP 409.
- `GET /logs` exposes the bounded recent Helper/Core stderr buffer with `no-store` caching. - `GET /logs` exposes the bounded recent Helper/Core stderr buffer with `no-store` caching.
All endpoints bind only to `127.0.0.1:47890` and do not use request-token authentication. Lifecycle safety comes from the Endpoints bind only to `127.0.0.1:47890` on Windows and to `/run/flclash/helper.sock` on Linux, and do not use
fixed executable/hash, strict pipe namespace, session-scoped stop contract, and Dart-side peer-PID verification. When the request-token authentication. Lifecycle safety comes from the fixed executable/hash, the strict address namespace
Helper service itself shuts down, it unconditionally stops the Core process it owns. (`\\.\pipe\FlClashCore_<32 hex>` on Windows, `/tmp/FlClashSocket_<digits>.sock` on Linux), the session-scoped stop
contract, Dart-side peer-PID verification on Windows and, on Unix, the Core socket that `plugins/rust_api` sets to
mode `0600` so only the owning user (and the root-effective Core) can connect. When the Helper service itself shuts
down, it unconditionally stops the Core process it owns; under systemd the unit's control group does the same.
+126 -14
View File
@@ -17,26 +17,45 @@ dart setup.dart windows
dart setup.dart android dart setup.dart android
``` ```
Build only the Go core and skip Flutter packaging: The Go core and the Rust helper build automatically: Flutter runs
`plugins/setup/hook/build.dart` on every `flutter build` and `flutter test`,
and the hook drives `CoreBuilder` from the `setup_hooks` package in
`plugins/setup/setup_hooks/`. Android builds take the NDK from the C compiler
Flutter hands the hook; no `ANDROID_NDK` variable is needed.
Artifacts land in `libclash/`. The hook reruns only when Go, Rust, or
`setup_hooks` inputs change; the fingerprint cache lives in
`.dart_tool/setup_build_cache/`. To force a rebuild, delete that directory:
```bash ```bash
make core-macos rm -rf .dart_tool/setup_build_cache
make core-linux
make core-windows
make core-android
``` ```
Pass `ARCH` or `TARGET_PLATFORM` through `make` when needed, for example: Flutter hides the hook's output on success, so to see why the Core was or was
not rebuilt read `.dart_tool/setup_build_cache/hook.log`; each invocation starts
with a `===` line carrying its timestamp and target.
```bash The hook runs for `flutter test` and for `dart run` of any root-package script
make core-macos ARCH=arm64 too, so a pure Dart test run needs the Go toolchain, and on Linux and Windows
make core-android TARGET_PLATFORM=android-arm64 also `cargo` and `rustc` — the Helper fingerprint shells out to both even on a
cache hit. The hook protocol carries no build mode and no caller, so the only
switch is the user-define in the root `pubspec.yaml`:
```yaml
hooks:
user_defines:
setup:
build_assets: true # false turns the Go core and Helper hook into a no-op
rust_api:
build_assets: true # false does the same for the Rust library
``` ```
Core builds use setup's input fingerprint cache. Pass `FORCE=1` to bypass it, CI flips both to `false` with `yq` before `dart run tool/changelog.dart` and
for example `make core-macos ARCH=arm64 FORCE=1`. `flutter test`, and restores the file afterwards; no test loads either library.
Never commit `false`: a build with it set stages whatever `libclash/` already
The Makefile wraps `plugins/setup/buildkit/run_build_tool.sh`; prefer the `make` entry points unless debugging the build tool itself. holds and bundles no Rust library, which is why `setup.dart` refuses to package
while it is set. Locally, `false` is worth setting for a Dart-only test loop,
but the hook cache keys on the user-define, so the first build afterwards runs
the hooks again.
## Flutter Development ## Flutter Development
@@ -71,6 +90,19 @@ Generated output paths, configured in `build.yaml`:
- `lib/providers/generated/*.g.dart`. - `lib/providers/generated/*.g.dart`.
- `lib/database/generated/*.g.dart`. - `lib/database/generated/*.g.dart`.
Tray and Windows app icons are generated, not hand-edited. `assets_source/images/icon/*.svg` is
the source of truth; the script needs `rsvg-convert` (librsvg) on `PATH`:
```bash
dart run tool/generate_status_icons.dart
```
It writes the tray PNGs with Flutter `2.0x/`–`4.0x/` resolution variants to `assets/images/tray/unix/`,
multi-size tray `.ico` files to `assets/images/tray/windows/`, and `windows/runner/resources/app_icon.ico`
from `assets/images/icon.png`. `pubspec.yaml` declares the two tray directories with `platforms:` so each
build only bundles the format its tray loads; a new status icon needs a source SVG and an entry in the
script's `statusIconNames`, nothing in `pubspec.yaml`.
## Testing ## Testing
Tests use `package:test/test.dart` for pure Dart logic and `flutter_test` for provider and widget tests. `mocktail` is the mocking framework. Tests use `package:test/test.dart` for pure Dart logic and `flutter_test` for provider and widget tests. `mocktail` is the mocking framework.
@@ -87,7 +119,11 @@ flutter test test/setup_test.dart
flutter test plugins/proxy/test/proxy_test.dart flutter test plugins/proxy/test/proxy_test.dart
``` ```
Root `flutter test` only discovers the root package's `test/` directory by default. Include bundled plugin Dart tests by passing paths explicitly, or run `flutter test` from that plugin package directory. Native plugin tests under platform folders are not run by `flutter test`. `plugins/setup/setup_hooks/` is a pure Dart package and is tested with `dart test` from its own directory, which is
also what CI runs; `tool/check_plugins.sh` does not descend into it. It must stay out of `flutter test`, because a test
run of `plugins/setup` would execute the build hook itself.
Root `flutter test` only discovers the root package's `test/` directory by default. Include bundled plugin Dart tests by passing paths explicitly, or run `flutter test` from that plugin package directory, or run `bash tool/check_plugins.sh` to analyze and test every plugin package the way CI does. Native plugin tests under platform folders are not run by `flutter test`.
For the current Core/service architecture, useful focused checks are: For the current Core/service architecture, useful focused checks are:
@@ -142,6 +178,66 @@ JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradl
Always-on VPN entry, system VPN revoke, actual permission UI, and rapid device start/stop still require Android device or Always-on VPN entry, system VPN revoke, actual permission UI, and rapid device start/stop still require Android device or
emulator validation; Kotlin compilation cannot prove those system callbacks. emulator validation; Kotlin compilation cannot prove those system callbacks.
## Changelog And Release
The changelog is derived from Conventional Commits by `tool/changelog.dart` and written to two committed files:
`CHANGELOG.md` for readers and `changelog.json` for the renderers. See `.agents/rules.md` for the `Changelog:` trailers
that decide the wording.
The app ships no changelog of its own. `render release` appends the released version as JSON inside an HTML comment
(`<!-- flclash:changelog:json … -->`), so the release body GitHub already returns to `checkForUpdate` carries the
notes shown in the update dialog. `parseReleaseChangelog` reads that block and falls back to the English
bullets when a release predates it.
```bash
dart run tool/changelog.dart verify # what CI checks
dart run tool/changelog.dart release --version 0.8.96
dart run tool/changelog.dart build --unreleased # changelog.json only, includes untagged work
dart run tool/changelog.dart render release --out release.md
dart run tool/changelog.dart render telegram --out telegram.md
```
Releasing a stable version, in order:
```bash
tool/bump_version.sh all
dart run tool/changelog.dart release --version 0.8.96
git commit -am "chore(release): v0.8.96"
git tag v0.8.96
git push origin main && git push origin v0.8.96
```
Push the tag by name. Every release tag here is lightweight, and `--follow-tags` carries annotated tags only: it skips a
lightweight one silently, so the branch lands, the tag does not, and the release workflow never fires.
`tool/release.sh` drives both paths so the ordering below cannot be got wrong by hand. It resolves the version (bumping
the patch when pubspec still names an already tagged one), prints the notes the tag would ship, and only pushes with
`--push`:
```bash
tool/release.sh pre --dry-run # plan and notes, changes nothing
tool/release.sh pre --push # bump, tag vX.Y.Z-pre.N, push
tool/release.sh stable --push # changelog, chore(release) commit, tag, push
```
The release commit comes before the tag on purpose: the generated wording is reviewable in the diff before it ships, and
the tag is what `render release` reads. CI never writes back to the repository; it only runs `verify`. Wording in
`changelog.json` may be edited by hand as long as no derivable entry disappears and every entry still points at a commit
inside that version's range.
Entries at or below `v0.8.96` are frozen: they predate the pipeline, live under the `<!-- changelog:frozen -->` marker
in `CHANGELOG.md`, and are never regenerated.
`verify` compares a version only when its tag is reachable from `HEAD`, because that is the same scope the builder walks
(`git tag --merged`). A branch cut before the newest release cannot derive that version at all, so `verify` names it as
skipped and moves on instead of reporting drift that does not exist. Checking mere tag existence is what made every such
branch fail on an unrelated release.
Prerelease tags (`v0.8.96-pre.N`) skip the release commit, and CI renders their notes with `build --unreleased` for the
Telegram post. They publish no GitHub release, so the update dialog never sees them. `build --unreleased` reads the
version from `pubspec.yaml` rather than the tag, so the patch has to be bumped before the first `-pre.N` of a cycle:
while `v<pubspec version>` is still tagged it refuses to collect anything and the release job fails.
## Verify ## Verify
The tag-triggered release workflow runs these root-package checks in order: The tag-triggered release workflow runs these root-package checks in order:
@@ -149,7 +245,10 @@ The tag-triggered release workflow runs these root-package checks in order:
```bash ```bash
flutter pub get flutter pub get
flutter analyze --no-fatal-infos flutter analyze --no-fatal-infos
dart run tool/changelog.dart verify
flutter test --reporter expanded flutter test --reporter expanded
bash tool/check_commit_msg_test.sh
bash tool/check_comment_density_test.sh
``` ```
Run `flutter analyze` locally before committing when practical. Run `flutter analyze` locally before committing when practical.
@@ -160,3 +259,16 @@ plugin packages, so CI also validates local Flutter packages, the setup build
tool, the Go wrapper, and Rust components from their own package directories. A tool, the Go wrapper, and Rust components from their own package directories. A
separate Windows runner compiles and tests the helper's `windows-service` separate Windows runner compiles and tests the helper's `windows-service`
feature before release builds can start. feature before release builds can start.
`bash tool/check_plugins.sh` is that plugin gate, and CI runs the same script.
It discovers every `plugins/*/pubspec.yaml`, analyzes each package, and runs
`flutter test` wherever `test/*_test.dart` exists. Adding a plugin package needs
no workflow edit; enumerating packages by hand in the workflow is what
previously left `plugins/tray` unanalyzed and untested.
## Worktree Tooling
```bash
bash tool/worktrees.sh list # every worktree with owner tool and dirty/clean state
bash tool/worktrees.sh prune # remove clean worktrees; add --force to drop dirty ones too
```
+116 -4
View File
@@ -4,22 +4,134 @@ FlClash is a multi-platform proxy client based on ClashMeta (mihomo), built with
## Version Notes ## Version Notes
- Release CI pins Flutter 3.44.4. Local SDK may diverge, so trust the CI - Release CI pins Flutter 3.47.1. Local SDK may diverge, so trust the CI
version as the source of truth for release builds. version as the source of truth for release builds.
- Dart SDK constraint: `>=3.8.0 <4.0.0`. - Dart SDK constraint: `>=3.8.0 <4.0.0`. The lower bound is load-bearing and
must not be raised to the Dart version the SDK actually ships; see
Dependency Ceilings.
## Forked Dependencies
Three `pubspec.yaml` dependencies are pinned to a fork — `window_manager` by tag,
the other two by commit SHA. All three
forks live under `chen08209`, the same account that owns this repository, so they
are maintained in-house rather than tracked from a third party: advancing a pin
is a local decision, and there is no external maintainer to wait on for the patch
itself. What each fork still waits on is the *upstream* fix that would let the
pin be dropped entirely, recorded below.
Each entry records what the fork changes and what has to be true before it can go
back to the published package, so a future upgrade does not have to rediscover it.
Re-verify a fork by diffing its pub cache checkout against the published version
of the same number:
```bash
diff -ru ~/.pub-cache/hosted/pub.dev/<name>-<version> ~/.pub-cache/git/<name>-<sha>
```
`window_manager` — `chen08209/window_manager`, path `packages/window_manager`,
version 0.5.1, pinned to the tag `v0.5.1-flclash.1` because the fork carries
commits of its own rather than a single patch on top of a release.
- `windows/window_manager_plugin.cpp`: with `titleBarStyle: hidden` a maximized
window uses the monitor work area (`GetMonitorInfo().rcWork`) instead of
upstream's `adjustNCCALCSIZE` border fudge, so it no longer covers the taskbar.
- `linux/window_manager_plugin.cc`: GTK drops the placement of an unmapped
window, so `hide` saves the geometry and the `map-event` handler applies it
again. Upstream only moves the window while it is hidden, which a window
manager is free to ignore — the window then reappears wherever it decides to
place it, which on this repository's Linux runner is every appearance after the
first, because `my_application.cc` never shows the toplevel itself.
- Adds `setWindowCornerPreference` (Windows) and `handleShouldTerminate` /
`onWindowShouldTerminate` (macOS). These lived in a local `window_ext` plugin
until they moved here; `lib/manager/window_manager.dart` and
`macos/Runner/AppDelegate.swift` are the callers.
- Drop the fork once upstream carries all three. The added APIs have call sites,
so this is not a pin change alone.
`launch_at_startup` — `chen08209/launch_at_startup`, version 0.5.1.
- Migrates `win32_registry` from `^2.0.0` to `^3.0.3`, which is a breaking rename
across the whole Windows implementation (`Registry.openPath` → `CURRENT_USER.open`,
`createValue` → `setValue`, `getStringValue` → `getString`).
- This one is not optional while it lasts: FlClash depends on `win32_registry: ^3.0.3`
directly, and upstream's `^2.0.0` constraint cannot co-resolve with it.
- Drop the fork when upstream publishes a release that accepts `win32_registry` 3.x.
`yaml_writer` — `chen08209/yaml_writer`, version 2.1.0.
- Adds `StringNode.quoteKey()` and applies it to map keys in `lib/src/node.dart`.
Upstream quotes values but emits keys verbatim, so a profile key needing quotes
is written as invalid YAML.
- Drop the fork once upstream quotes map keys by the same
`isValidUnquotedString` rule it already applies to values.
## Dependency Ceilings
Several dependencies cannot be advanced from this repository, and re-running
`flutter pub outdated` will keep listing them. The blocker is upstream in every
case, so treat the list as resolved-until-the-ceiling-moves rather than as debt:
`freezed` is pinned exactly to `3.2.6-dev.1`, which is a pre-release *ahead* of
the newest stable `3.2.5`. It is not a stale pin and must not be "fixed" by
moving to `3.2.5`: stable `3.2.5` requires `analyzer >=9.0.0 <11.0.0`, while the
pinned Flutter SDK resolves `analyzer` 12. `3.2.6-dev.1` is the only published
freezed release that accepts `analyzer` 12. Move to a stable release only once
one exists that accepts the analyzer the SDK actually resolves.
The `>=3.8.0` Dart lower bound is a language-version floor, not a stale minimum.
Dart 3.13 makes `final` on a parameter an error, and `freezed` still emits it in
the constructors it generates for every collection field it backs with a private
field (`const _LogsState({final List<Log> logs = const [], ...})`); 4.0.0-dev.3
emits it too. A pubspec's lower bound sets the package language version, so
`>=3.8.0` keeps that generated code legal while the SDK itself runs 3.13. Raising
the bound makes every `*.freezed.dart` fail to parse, which `flutter analyze`
does not catch because `lib/**/generated/**` is excluded. Raise it only once
freezed stops emitting the modifier.
One `analyzer` ceiling holds most of the remaining `flutter pub outdated` list.
The newest `build_runner`, `drift_dev`, `intl_utils`, `test`, and
`riverpod_generator` all require `analyzer` 13; `test` 1.31.2 additionally
requires `test_api` 0.7.13, while `flutter_test` from the pinned SDK depends on
`test_api` 0.7.12. Raising any of those bounds therefore fails version solving.
The `riverpod` chain is the visible symptom. It is held at
`riverpod`/`flutter_riverpod` 3.3.2, `riverpod_annotation` 4.0.3, and
`riverpod_generator` 4.0.4 as one rigid unit, because `riverpod_annotation`
4.0.3 depends on `riverpod` exactly 3.3.2: the runtime cannot move without the
generator, and the generator cannot move without `analyzer` 13. Retry the whole
set after a Flutter SDK bump raises `test_api`, not before.
`intl` is intentionally unbounded (`any`) and `material_color_utilities` is
resolved by the SDK; neither is a bound this repository sets.
`isolate_contactor` is discontinued and `isolate_manager` is several majors
behind. Both arrive through `re_editor`, which pins `isolate_manager: ^4.1.5+1`
and is already at its own latest release. Nothing in this repository can advance
them.
`CorePalette` is deprecated in `material_color_utilities` in favour of
`DynamicScheme`/`CorePalettes`, but `dynamic_color` 1.9.0 — its newest release —
still exposes only `DynamicColorPlugin.getCorePalette()`, which returns the
deprecated type. There is no migration available from this repository short of
calling the `io.material.plugins/dynamic_color` method channel directly and
decoding the palette int list by hand, which is not worth owning. The deprecated
type is therefore confined to `GlobalState._initDynamicColor`, which converts it
to the two plain seed colours the app actually consumes; everything downstream
sees `DynamicColorSeeds`. Revisit when `dynamic_color` ships a non-deprecated
accessor — only that one function has to change.
## Build Dependencies ## Build Dependencies
Linux: Linux:
```bash ```bash
sudo apt-get install libayatana-appindicator3-dev libkeybinder-3.0-dev sudo apt-get install libayatana-appindicator3-dev
``` ```
Windows: Windows:
- GCC and Inno Setup. - GCC and Inno Setup.
- `ANDROID_NDK` env var for Android builds.
macOS: macOS:
+394 -27
View File
@@ -4,7 +4,11 @@ These are repository coding and testing conventions. Codex command permission ru
## Dart and Flutter Style ## Dart and Flutter Style
`analysis_options.yaml` enforces these non-default rules: The lint set lives in `lint_options.yaml` at the repo root. The root `analysis_options.yaml` and every local plugin under
`plugins/*` include it, so application and plugin code are held to the same rules. Add or change a rule there, not in an
individual `analysis_options.yaml`; those files carry only their own `analyzer.exclude` entries.
`lint_options.yaml` enforces these non-default rules:
- `prefer_single_quotes: true`: always use single quotes. - `prefer_single_quotes: true`: always use single quotes.
- `require_trailing_commas: true`: use trailing commas in multi-line argument lists. - `require_trailing_commas: true`: use trailing commas in multi-line argument lists.
@@ -13,6 +17,38 @@ These are repository coding and testing conventions. Codex command permission ru
- `prefer_const_constructors: true` and `prefer_const_declarations: true`. - `prefer_const_constructors: true` and `prefer_const_declarations: true`.
- `prefer_final_locals: true` and `prefer_final_in_for_each: true`. - `prefer_final_locals: true` and `prefer_final_in_for_each: true`.
- `always_declare_return_types: true`. - `always_declare_return_types: true`.
- `only_throw_errors: true`: throw an `Exception` or `Error`, never a bare `String`.
Failures whose whole content is a message meant for the user throw
`MessageException` from `lib/common/exception.dart`. Its `toString()` is the bare
message, which is what `globalState.safeRun` surfaces in the dialog, so the
user-facing text is unchanged from the older `throw someMessage` idiom while the
throw stays catchable as an `Exception` and carries a stack trace. Assert on it
with `isA<MessageException>().having((e) => e.message, 'message', ...)`, not on a
raw string.
### Corner Radius
Rounded corners are superellipses everywhere, not circular arcs. Use the superellipse API at each layer:
- Shapes: `RoundedSuperellipseBorder` instead of `RoundedRectangleBorder`.
- Clips: `ClipRSuperellipse` instead of `ClipRRect`.
- Container decorations: `ShapeDecoration(shape: RoundedSuperellipseBorder(...))` instead of
`BoxDecoration(borderRadius: ...)`; borders move to the shape's `side`, and a `Container` with
`clipBehavior` still clips to the shape path.
- Canvas: `canvas.drawRSuperellipse(RSuperellipse.fromRectAndRadius(...))` instead of `drawRRect`.
Passing `BorderRadius.circular(x)` as the `borderRadius` argument of these APIs is expected — it only
carries the corner magnitude; the rendered geometry stays a superellipse.
APIs that accept only `BorderRadius` keep circular corners, with the superellipse supplied by an
enclosing clip or shape where one is needed: `InkWell.borderRadius`, `OutlineInputBorder`,
`ScrollbarThemeData.radius`, and `smooth_sheets`' `MaterialSheetDecoration`. Fully round pills
(`BorderRadius.circular(999)` or half the shortest side) may stay circular — both geometries coincide
there.
CI gates formatting: `dart format --output=none --set-exit-if-changed lib test
tool plugins setup.dart` runs before `flutter analyze`.
Generated directories are excluded from analysis: Generated directories are excluded from analysis:
@@ -23,55 +59,182 @@ Generated directories are excluded from analysis:
## Comments ## Comments
Comments are opt-in and reserved for the few places that genuinely need one. Density is the point: every comment that Comments are part of the code and some of them are load-bearing. What this repository limits is the useless and the
restates the code devalues the comments that carry real information, until readers skim past all of them. A file with frequent. Every comment that restates the code devalues the ones carrying real information, until readers skim past all
three comments that matter is more readable than one with thirty. of them — a file with three comments that matter is more readable than one with thirty.
### Writing Comments The two halves are enforced differently, because only one of them can be counted. Whether a comment is useless is a
judgment, and it lives in the rules below and in review. Whether comments are too frequent is arithmetic, and
`comment-density` measures it.
- Never add a comment on your own initiative. This covers explanatory, narrative, TODO, section-divider, and ### What a Comment Must Earn
documentation comments, in Dart, Kotlin, Swift, Go, Rust, YAML, Gradle, and any other file you touch.
- Never annotate line by line or statement by statement, and never restate in prose what the code already says. If a - Write one when it carries something the code cannot: a non-obvious constraint, an upstream or platform behavior being
block needs a comment per step, the block needs better names or a smaller decomposition instead. worked around, a reason the next reader would otherwise get wrong, a coupling to a value defined somewhere else.
- When a change genuinely cannot be understood without a comment, do not write it silently. Explain what is unclear, - Never restate what the code already says. If the comment and the line below it convey the same thing, the comment is
propose the exact comment text, and wait for the user to approve it before adding it. noise.
- Never narrate. Not what this diff changed, not what the code used to be, not the order you did things in. Version
history belongs in git and change rationale belongs in the commit message.
- Never annotate line by line or statement by statement. A block that seems to need a comment per step needs better
names or a smaller decomposition instead — reach for structure before prose.
- Prefer encoding the intent in structure and naming. A named mixin, type, or method that makes the invariant hard to
break beats a paragraph asking the next reader not to break it.
- Delete commented-out code, stale version notes, and comments that only restate the code, whenever you edit the file - Delete commented-out code, stale version notes, and comments that only restate the code, whenever you edit the file
that contains them. This does not need approval. that contains them. This does not need approval.
- These are not comments and must be preserved: analyzer and linter directives (`// ignore:`, `// ignore_for_file:`, - These are not comments and are exempt from every rule here, including the density count: analyzer and linter
`// coverage:ignore`), license and copyright headers, code-generation markers, and comments inside vendored upstream directives (`// ignore:`, `// ignore_for_file:`, `// coverage:ignore`), license and copyright headers,
code such as `lib/widgets/open_container.dart`. code-generation markers, and comments inside vendored upstream code such as `lib/widgets/open_container.dart`.
### Density
`tool/check_comment_density.sh` fails a file whose added lines are more than 5% standalone comment lines, ignoring
diffs under 20 added lines so small edits are never caught. The number is calibrated on this repository's own history:
healthy changes sit at or under 3.6%, while the core fix that prompted the gate ran 22.4%.
It is a ceiling on frequency, not a target to fill. Do not read `core/`'s inherited mihomo density as a quota either —
it is forked upstream code, not a house style.
Three gates run the same script, and they do not have equal force. The `PostToolUse` hooks in `.claude/settings.json`
and `.codex/config.toml` run after the tool and hand offending lines back as feedback; neither can undo the completed
write. The `comment-density` pre-commit hook fails the commit, and it is the only hard gate that covers every tool.
Behavior is pinned by `tool/check_comment_density_test.sh`, which CI runs directly.
When a change genuinely warrants more, raise the ceiling for that run rather than working around it:
`COMMENT_DENSITY_MAX=20 git commit`, or `SKIP=comment-density git commit` to step past it entirely.
### Where Knowledge Belongs ### Where Knowledge Belongs
Pick the destination by where the constraint would be violated, not by how important it feels. A comment is one of three destinations, and often the weakest. Pick by where the constraint would be violated, not by
how important it feels.
- **Assertable behavior goes in a test.** A test is the only form that cannot drift, because it fails when the behavior - **Assertable behavior goes in a test.** A test is the only form that cannot drift, because it fails when the behavior
it describes is broken. Prefer it over both a comment and a document whenever the fact can be checked in code. it describes is broken. Prefer it over both a comment and a document whenever the fact can be checked in code.
- **Repository-wide defaults, ownership, and invariants go in `.agents/*.md` or a `.agents/skills/*/SKILL.md`.** They - **Repository-wide defaults, ownership, and invariants go in `.agents/*.md` or a `.agents/skills/*/SKILL.md`.** They
are violated from many files, so they must reach every future agent at session start. A comment in one file cannot do are violated from many files, so they must reach every future agent at session start. A comment in one file cannot do
that. that.
- **A fact that is true only at one call site, and is not visible from that call site, stays a comment there.** Its - **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
value is being in the reader's line of sight at the moment of the edit. `lib/common/constant.dart` is the model case: better than a test or a document.** Its value is being in the reader's line of sight at the moment of the edit.
the delay-test concurrency cap is bound to `mBatch` in `core/common.go`, and whoever changes that number must see the `lib/common/constant.dart` carries one: the delay-test concurrency cap is bound to `delayTestConcurrency` in
constraint on the same screen. `core/common.go`, and whoever changes that number must see the constraint on the same screen. This is the case a
comment is for; the bar is that no test and no `.agents/` entry could hold the fact instead.
Both failure directions are real. Moving a local constraint into `.agents/` hides it from the person editing the line; Both failure directions are real. Moving a local constraint into `.agents/` hides it from the person editing the line;
leaving a repo-wide policy as a comment reaches only the reader of that one file. leaving a repo-wide policy as a comment reaches only the reader of that one file.
Before any of the three, prefer encoding the intent in structure and naming — a named mixin, type, or method that makes
the invariant hard to break beats prose that asks the next reader not to break it.
## Core API Safety ## Core API Safety
- `core/Clash.Meta` is a fork of mihomo, and changes to it are budgeted for features, not repairs. Fixing a bug there is
low priority even when the bug is real and the fix is small: every patch is one more thing to carry across an upstream
rebase. Solve it on the FlClash side of the boundary and note the mihomo behaviour you are working around. Reach into
the submodule only for a feature that has nowhere else to live, or when the problem is one the FlClash patches
themselves introduced — and say which of the two it is in the commit message.
- Do not expose direct filesystem deletion APIs through Core or helper IPC; use - Do not expose direct filesystem deletion APIs through Core or helper IPC; use
a scope-specific cleanup API instead. a scope-specific cleanup API instead.
- The desktop IPC socket in `plugins/rust_api` admits a Unix peer only when its effective uid is the app's own or root:
the socket lives in `/tmp`, and the Core connects as root whenever it runs setuid for TUN or under the Linux Helper.
A launch mode that runs the Core as any other user has to widen `authorize_peer` in `ipc/platform.rs`, and Windows
keeps its identity check on the Dart side, which compares the named-pipe peer PID with the Core it launched.
- Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, JNI, and - Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, JNI, and
desktop IPC. Do not double-encode `arguments`, `result`, or event batches. desktop IPC. Do not double-encode `arguments`, `result`, or event batches.
- Keep high-volume log/request events separate from state-bearing events in `core/message.go`; bulk backpressure must not - `core/message.go` carries three event queues, and the split is load-bearing: state (loaded, geo-update), delay, and
evict delay, loaded-provider, or geo-update state. bulk (log, request). Delay and bulk evict their own oldest entry under backpressure; state uses `enqueueState`, which
never evicts, because a dropped `geoUpdate{updating:false}` leaves `isUpdatingProvider` stuck at true in the UI until
`UpdatingAction` sweeps it as stale minutes later. Do not merge the tiers or give state eviction semantics. `enqueueState` drops silently on a full
queue and must stay that way: reaching it means the host stopped reading, which `logDeliveryError` already reports,
and reporting it from the message layer feeds the same batcher.
- `jni_get_string` in `android/core/src/main/cpp/jni_helper.cpp` `malloc`s and hands ownership to Go, which frees through
`free_string_func`. `quickSetup` relies on that: it reads its `*C.char` arguments inside a goroutine, after the JNI
wrapper has already returned. Switching the wrapper to `GetStringUTFChars`/`ReleaseStringUTFChars`, or freeing on the
C side, turns that read into a use-after-free.
- Go goroutines reach Java through `ATTACH_JNI()`, which attaches once with `AttachCurrentThreadAsDaemon` and detaches
from a `pthread_key` destructor at thread death. Do not restore a detach-per-call: `protect` runs once per outbound
socket and `onResult` once per event batch, and attach/detach takes ART's thread-list lock each time.
- Every JNI call into Kotlin must be followed by `jni_clear_exception`. A pending exception left in place aborts the
process on the next JNI call on that thread, so a throw in `protect`/`resolveUid`/`resolvePackage`/`onResult` becomes a
crash in unrelated code. For the same reason every one of those wrappers checks its `tun_interface`/`invoke_interface`
for `nullptr` first: ART aborts on a call through a null object, and a callback released by `TunHandler.clear` while a
connection is still resolving is exactly that.
- The Android bridge resolves an owner in two steps — `resolve_uid` then `resolve_package` — because mihomo fills
`metadata.Uid` from a procfs lookup that Android Q closed off. Collapsing them back into one call that returns only a
package name is what left every connection reporting uid 0, so `UID` rules matched nothing.
- The desktop delivery path in `core/server.go` must not report failures through `logError`. A log event is published to
the log subscriber, batched, and handed back to `send`, so a send failure reported that way feeds itself; use
`logDeliveryError`, which writes to stderr and latches until a frame gets through or the next connection is installed.
A write that fails without putting a byte on the wire — host backpressure hitting `ipcWriteTimeout`, or a payload above
`maxIPCFrameSize` — drops that one frame and keeps the connection: the stream is still framed correctly, and tearing it
down here ends the read loop, and with it the Core process. Only a half-written frame desynchronizes the stream, and
that is the one case `send` closes on.
- Core method handlers in `core/hub.go` are synchronous. Anything that must not block the dispatcher is spawned by
`safeGo`/`safeGoDetached` in `core/method.go`, which recover; a bare `go` in a handler puts a panic outside every
recovery and kills the process, which on Android is the whole application. The `//export` entry points in
`core/lib.go` do not reach `handleMethodCall`, so each one carries its own recovery.
- `dialer.DefaultSocketHook` and `process.DefaultPackageNameResolver` are installed exactly once, by `installHooks` in
`core/lib.go`, and never cleared. mihomo checks `DefaultSocketHook` for nil once and dereferences it again when the
socket is created (`component/dialer/socket_hook.go`), so clearing it while a dial is in flight calls a nil func
value. Stopping the TUN swaps `activeTunHandler` instead.
- `tunnel.AllProxies()` returns a shared, cached map — never modify it. The cache is invalidated by
`invalidateAllProxies` on `tunnel.UpdateProxies` and validated against each provider's `Version()`, so a rebuild
costs one read per provider rather than one per proxy. Anything else added to `tunnel/patch.go` that derives from the
proxy set needs both signals: the external controller can reload the config through `hub/route/configs.go` without
going through FlClash's `applyConfig`, so a hook on the FlClash side alone would miss a profile switch.
- Core state that mirrors mihomo state goes stale at the next `applyConfig`, which replaces every proxy, provider and
rule. Read the tunnel instead of caching a snapshot of it: `lookupExternalProvider` kept one that was rebuilt only
when the host asked for the provider list, and the host asks after a successful setup and not after a failed one, so
an update ran against a provider the tunnel no longer held — downloading, writing to disk and reporting success
against nothing.
- Selection writes take `selectMu`, not `configMu`. mihomo's `Selector.Set` has no lock of its own, so the writes need
mutual exclusion against each other and against `patchSelectGroup` — but not against a whole config apply, which is
what `configMu` made a proxy switch wait for, provider downloads included. `patchSelectGroup` takes `selectMu` under
`configMu`, fixing the order as `configMu` → `selectMu`.
- The delay-test semaphore is acquired with a slice of the caller's budget (`budget/delayTestQueueShare`), not
unconditionally and not with the whole deadline. Queueing and probing come out of one budget, so a test handed all of
it can spend it waiting and reach `URLTest` with nothing left, reporting a proxy it never contacted as unreachable.
The probe keeps the caller's original deadline, so whatever the queue did not use is still its own.
- A delay test that the Core does not answer is a fault of the Core or the channel, never a verdict on the proxy:
`handleTestDelay` returns inside its own budget on every path. `asyncTestDelay` therefore returns null instead of a
`-1` delay, and `ProxiesAction` leaves the last measurement in place and abandons the rest of the run. Writing a
timeout there is what made a reachable node read as unreachable whenever the host deadline beat the Core's.
- Delay-test progress lives in `pendingDelayTestsProvider`, not as a sentinel value in `DelayDataSource`. A delay of 0
used to mean "testing", which let a result and the state of a test overwrite each other and left cards spinning
forever when the Core restarted. The run owns its keys and releases them in a `finally`, so nothing depends on a
reply arriving; core status leaving `connected` cancels every run in flight.
- Anything on the mihomo side that is reached from both a user-triggered core method and mihomo's own background
scheduler needs its in-flight guard on the FlClash side. `updater.UpdateMMDB` and its siblings have none — only the
batch `UpdateGeoDatabases` does — and two concurrent runs close the mmap'd database twice, so `handleUpdateGeoData`
claims per resource and `updater.GeoUpdateHook` releases.
- A failed `applyConfig` rolls the tunnel back to the default config, and that rollback is the whole recovery:
`handleSetupConfig` returns the error and stops there. Do not add a teardown on top of it — stopping the listeners
takes the app offline over a profile the user can still switch away from, and the error already reaches the host,
which is what surfaces the failure (`MessageException` on the Flutter side, the config-error toast on Android). Keep
an empty `config.yaml` out of the failure path — it is how the app says "no profile selected", and `loadConfig`
resolves it to the defaults.
- Package `init` in the Android library runs while the `.so` is being loaded, so a panic there takes the application
down before it can report anything. `platform/limit.go` arms an fd-pressure probe and degrades to never blocking when
it cannot; keep that shape for anything else `init` sets up that correctness does not depend on.
- Every `android && cgo` file in `core/` is compiled only by the NDK-backed CI step in the `test` job. Keep the build
constraints as `android && cgo` / `!(android && cgo)`: a bare `cgo` constraint makes `go build ./...` fail in `core/`
on any developer machine, because the files it pulls in need the NDK.
## Lifecycle Rules ## Lifecycle Rules
- Crash recovery is owned by `BootGuard` (`lib/common/boot_guard.dart`), and the signal it acts on is the persisted
`BootRecord`, never a crash reporter. The record is stamped `starting` before `startCore` and `running` once
`_initApp` finishes, so only a launch that died before reaching `running` is a failed launch — a crash after hours of
runtime, or a process the system reclaimed in the background, is not one. `ApplicationExitInfo` can only veto a
failure (user stop, low memory, signal, package change), never create one, and its timestamp is consumed through
`handledExitAt` so the same exit cannot be counted twice.
- `BootGuard` is an Android-only mechanism and gates itself: on every other platform `evaluate`, `markRunning` and
`markClosed` return without touching preferences. The desktop has neither of the two attribution sources — no
`ApplicationExitInfo` equivalent and no Crashlytics toggle — so a bare sentinel there would read a window closed on
the disclaimer dialog, or a session ended by shutdown, as a failed launch. Keep the platform check inside the guard;
callers in `bootstrap.dart` and `SystemAction` stay unconditional.
- `FirebaseCrashlytics.didCrashOnPreviousExecution()` corroborates a failure and must not trigger one. Its marker file is
cleared by a background initialization that `dataCollectionArbiter` blocks while collection is disabled, which is the
default here, so one real crash makes it return true on every later launch. That latch is what made the app clear the
selected profile on every cold start; it is also why the probe is only read when `crashlytics` is enabled, keeping
Firebase uninitialized until the user consents.
- Recovery is graded: the first failed launch only skips `initStatus`, and `currentProfileId` is cleared only from
`crashRecoveryClearThreshold` consecutive failures on. Do not let a single interrupted launch write to the config.
- Desktop process ownership belongs to `DesktopCoreLifecycle`; do not start/kill `FlClashCore` from providers, widgets, - Desktop process ownership belongs to `DesktopCoreLifecycle`; do not start/kill `FlClashCore` from providers, widgets,
managers, or ad hoc exit callbacks. Acquire and release it through a `CoreProcessLease`. managers, or ad hoc exit callbacks. Acquire and release it through a `CoreProcessLease`.
- `CoreController.close()` and platform `close()` implementations are terminal and idempotent. Application shutdown must - `CoreController.close()` and platform `close()` implementations are terminal and idempotent. Application shutdown must
@@ -84,18 +247,155 @@ the invariant hard to break beats prose that asks the next reader not to break i
broadcast lease, but must not cancel, reverse, or otherwise redefine the service operation. broadcast lease, but must not cancel, reverse, or otherwise redefine the service operation.
- Presentation smoothing such as `CoreStatusButton`'s connecting hold must remain local display state. It must not delay or - Presentation smoothing such as `CoreStatusButton`'s connecting hold must remain local display state. It must not delay or
overwrite `coreStatusProvider`, and a real failure must bypass/cancel the hold immediately. overwrite `coreStatusProvider`, and a real failure must bypass/cancel the hold immediately.
- `Tray.hide()` is idempotent on all three desktop platforms and returns native state to "`show` was never called".
`AppTray.shutdown()` latches, so no later `update()`/`updateTitle()` can resurrect the icon once shutdown begins.
Keep it that way; a resurrected icon outlives `exit(0)` as a Windows ghost icon, because `setPreventClose(true)`
means `WM_DESTROY` never runs.
- The `tray` plugin owns call ordering, idempotency, serialization, and unchanged-payload suppression. Application code
declares desired state through one `Tray.show(TraySpec)` call and must not add platform branches to work around
ordering. Platform branches in `lib/common/tray.dart` are only for deliberate product differences (macOS speed title
and group submenus); query `Tray.instance.capabilities` for ability differences.
- Every native `show` returns whether the tray now reflects the payload, and reports `false` instead of showing a broken
icon. `Tray` caches the payload signature only on `true`, so a rejected `show` is retried by the next update rather
than suppressed until restart. Any test that mocks the `tray` channel must return `true` from `show`.
- Reading the Wi-Fi SSID is opt-in work, not ambient state. `ConnectivityManager` reads it only while `excludeSSIDs` is
non-empty, because that list is its only consumer through `suspendProvider`, and the read costs a blocking platform
call plus a location permission on Android and macOS. A second consumer must widen that gate, not drop it.
- On Android the `wifi_ssid` permission is only `granted` once `ACCESS_BACKGROUND_LOCATION` is held too (Q+). Fine
location alone reads the SSID in the foreground but returns `UNKNOWN_SSID` once the app is backgrounded, which is
where on-demand spends most of its time. `getSsid` itself only needs fine location, so the split stays inside the
plugin.
- No `wifi_ssid` implementation may answer `getSsid` on the platform thread. Android resolves through a network callback
with a timeout, Linux through `g_task_run_in_thread`, and macOS through `ssidQueue` after checking the location
authorization, because CoreWLAN reaches `wifid` over XPC and a wedged daemon would freeze the window. Windows is the
outstanding exception: `WlanQueryInterface` still runs inline, since replying off-thread there needs a window-message
hop that the plugin API does not provide.
- The delayed DNS re-check `NetworkObserveModule.onLosing` posts is deliberately left un-deduplicated. The runnable
re-reads `networkInfos` and does nothing when `onLost` already dropped the network, `updateDns` returns early when the
resolved list is unchanged, and `stop()` clears the handler queue, so a network that reports `onLosing` repeatedly
costs one comparison per event. Holding the pending `Runnable` to `removeCallbacks` it changes no behaviour and adds
state that has to stay in sync with the map.
## Testing Rules ## Testing Rules
The `core/` directory is excluded from automated coverage accounting. Do not add coverage instrumentation or coverage The `core/` directory is excluded from automated coverage accounting. Do not add coverage instrumentation or coverage
collection for code under `core/`. CI still runs `CGO_ENABLED=0 go test .` and `go vet .` to compile/check the Go wrapper; collection for code under `core/`. CI still runs `CGO_ENABLED=0 go test .` and `go vet .` to compile/check the Go wrapper,
verify cross-language protocol behavior through shared Dart contract tests under `test/core/` and native platform build plus an NDK-backed `GOOS=android` vet that covers the `android && cgo` files the first two exclude; verify cross-language
checks. protocol behavior through shared Dart contract tests under `test/core/` and native platform build checks.
A Go test in `core/` that reaches `sendMessage` — directly, or through `handleStartLog` or `updater.GeoUpdateHook` —
leaves events in the process-wide batcher, which flushes them up to `messageBatchInterval` later into whichever
connection `captureFrames` has installed by then. Either keep the event out of the batcher or end the test with
`settleMessageBatcher`. That batcher runs for the whole test binary and reads `conn` under `connMu`, so install a test
connection with `swapConn`; a bare assignment races every event it happens to be delivering, and `go test -race` catches
it in an unrelated test.
Use `CoreController.test(mock)` to inject a mocked `CoreHandlerInterface`. Call `CoreController.resetInstance()` in `tearDown` to clean up the singleton between tests. Use `CoreController.test(mock)` to inject a mocked `CoreHandlerInterface`. Call `CoreController.resetInstance()` in `tearDown` to clean up the singleton between tests.
Register fallback values for freezed params used with `any()` matchers. Register fallback values for freezed params used with `any()` matchers.
`tool/check_coverage.dart` enforces a total floor passed by CI plus per-group floors declared in `_groupFloors`. Raise a
group's floor when new tests lift it; do not lower one to make a run pass.
Every measured group needs a floor. A group the report measures but `_groupFloors` does not declare fails the run, so
adding a top-level directory under `lib/` means adding its floor in the same change. Set a new floor at or just below
the coverage the directory actually has; the point is to stop a slide, not to backfill tests before the directory can
land.
Prefer `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))` over `CoreController.test(fake)` in new and
touched tests. `CoreController.test` claims the process-wide singleton, which makes a global read and a provider read
resolve to the same fake, so it cannot fail on a call site that still reaches for the global.
Construct the Android lib handler with `CoreLib.scoped(fakeService)`. The `service` global is gated on `Platform.isAndroid`
and is therefore null on every test host, so a `CoreLib()` built from it silently takes the null-service fallback on every
path. `CoreLib.scoped` binds an explicit `Service` instead; reset the singleton with `CoreLib.resetInstance()` in `tearDown`.
Three globals in `lib/common` reach real host state and carry a `@visibleForTesting` seam to stand in front of it:
`AutoLaunch.launcher`, `listNetworkInterfaces` and `LinkManager.uriLinkStream`. Replace the launcher in particular — every
`enable`/`disable`/`isEnabled` writes the actual autostart entry (a LaunchAgents plist, a `.desktop` file or a registry
key), so a test that skips the seam registers the test binary on the machine that ran it. `updateStatus` returns early
under `kDebugMode`, which is always true beneath `flutter test`, so its remaining branches cannot be reached from a test
at all; `test/common/launch_test.dart` pins the early return instead.
`pumpAndSettle` never returns on a page holding `EditorPage`: the code editor blinks its caret forever, so frames keep
being scheduled. Pump explicitly instead. `encodeYamlTask` and its neighbours in `common/task.dart` hand work to a real
isolate through `compute`, which only runs outside the fake-async zone, so a test awaiting one needs
`tester.runAsync(...)` between the pumps — see `test/views/profile_preview_test.dart`.
The `@visibleForTesting` `database` setter in `lib/database/database.dart` deliberately does not close the instance it
replaces. Tests inject `NativeDatabase.memory()`, which holds no file handle, and `Database.close()` is async while the
setter is not, so closing there would either be unawaited or force the seam to become async for no gain. A test that
does open a file-backed database owns closing it.
`system.isAndroid` / `isMacOS` / `isWindows` / `isLinux` read `dart:io` `Platform` and cannot be overridden, unlike
`debugDefaultTargetPlatformOverride`. A branch behind one of them is only ever exercised on a host that matches it, so CI
(`ubuntu-latest`) and a macOS working copy measure different coverage for the same test. Assert host-agnostic behavior,
and leave headroom under a group floor that covers such a branch.
A platform decision that drives layout takes `isDesktop`/`isMacOS` as parameters and reads `system` only at the call site,
so every platform's outcome is reachable from one host. `getWindowHeaderHeight` and `showsWindowHeader` in
`lib/common/layout.dart` own the window header rule for both `WindowHeaderContainer` and `overlayTopOffset` — they must
agree, or the content is offset by a header that is not there. `WindowHeaderBar` takes its height and slots as arguments,
which is what lets `test/manager/window_header_test.dart` measure the Windows caption bar on a macOS host.
A `Stack` under loose constraints sizes to its non-positioned children, and falls back to `constraints.biggest` only when
it has none — so a bar that fills the window must keep every slot positioned. One non-positioned child is enough to
collapse `WindowHeaderBar` to that child's width: the macOS title did exactly that, leaving the app name over the traffic
lights and the raw window painted black beside it, while Windows, whose slots are all positioned, stayed correct.
`WindowHeaderLayout` positions the header across the top for the same reason, and the macOS group asserts the bar's
width, not just its height — a height-only assertion cannot see this.
The same seam carries the two other places a platform decision changed what was built: `AppTray` holds `isMacOS`/
`isWindows` as state — `AppTray()` fills them from `system`, `AppTray.forPlatform` is the test seam — and `OnDemandView`
takes nullable `isAndroid`/`isMacOS` that fall back to the host. Every test names the platform it means, because
`debugDefaultTargetPlatformOverride` does not move `system` and a suite that leaves it to the host asserts the macOS
shape on a developer machine and the Linux one on CI. `WindowHeaderContainer` builds the caption buttons on every
non-macOS host, so a test mounting it needs `TestApp` for `AppLocalizations` and a `window_manager` channel mock that
answers `isMaximized`/`isAlwaysOnTop` with a bool.
Auto-dispose providers need a container-level hold before a test reads them back. `proxyGroupProvider`, `ruleProvider`,
`itemsProvider` and friends mix in `AutoDisposeNotifierMixin`, so a `container.read` that no widget is currently watching
rebuilds the provider from its override and silently discards whatever the code under test wrote. Add
`container.listen(theProvider, (_, _) {})` in the harness, as `overwrite_stage_flow_test.dart` does. The staging flow also
re-arms its debounce when it clears the stage, so drain it (`pump` past the duration, then unmount) or the binding fails
the test on a pending timer.
A field that constructs its own `ValueNotifier`, `TextEditingController`, `ScrollController`, `FocusNode`, `TabController`,
`PageController`, `AnimationController` or `StreamController` must be released in the same file.
`test/lint/disposable_field_test.dart` enforces this by scanning `lib/`, because no lint covers it: `close_sinks` only sees
sinks, and nothing in the standard set tracks `ChangeNotifier` disposal. A field that genuinely outlives its owner goes in
that test's `_allowed` set with the reason, not left bare. Controllers received as widget parameters belong to the caller
and are out of scope.
An `IconButton` whose icon is an icon needs a `tooltip`. It is the button's only accessible name — without it TalkBack and
VoiceOver announce nothing and the desktop build shows no hover hint. `test/lint/icon_button_tooltip_test.dart` enforces
it and skips exactly two shapes: an `icon:` holding a `Text`, which is already a visible label, and
`views/dashboard/widgets/core_status_button.dart`, which takes its label from an enclosing `Tooltip` (a second test fails
if that wrapper disappears). Reuse an existing string before adding one; a label that depends on state goes on the button
inside the `ValueListenableBuilder`, not outside it, or the tooltip cannot follow the icon. A row of window buttons hidden
behind `system.isMacOS` is unreachable from a macOS test host, so extract it — `WindowHeaderActions` is the pattern.
A tooltip needs an `Overlay` ancestor at build time, not at hover time: `RawTooltip` builds an `OverlayPortal`, so a
button whose `tooltip` has nowhere to go throws "No Overlay widget found" and takes its whole subtree down with it.
Everything `buildManagerStack` wraps around `MaterialApp.builder`'s child sits *above* the app Navigator and therefore
above the only Overlay in the tree. A manager that renders a tooltip — `WindowHeaderLayout` and its caption buttons are
the case that broke Windows while macOS, which renders a bare title there, stayed clean — hosts its own with
`Overlay.wrap`, spanning the window so the tooltip is not clipped to the widget that owns it. A test only reproduces this
by mirroring that topology: build the widget from `MaterialApp.builder`, never from `home:`.
A public top-level declaration that nothing outside its own file references is dead, and no lint catches it:
`unused_element` covers only private ones, and a barrel `export` keeps a dead file compiling and off every
"unused import" report. `test/lint/dead_file_test.dart` scans `lib/` for files whose declared names — types plus the
`final appPath = AppPath()` singletons next to them — appear nowhere else, counting generated code as a consumer (a
riverpod notifier is reached through its generated provider) and barrels as neither. Files publishing only extensions or
typedefs are skipped: those are reached through the types they attach to, never by name.
A `State.dispose()` override must not await before `super.dispose()`. `StatefulElement.unmount` calls `dispose()` and then
immediately asserts that `super.dispose()` already ran, so an `await` defers the call past the assert and every teardown
throws "`…State.dispose failed to call super.dispose.`" in debug and profile builds. Declare the override as `void
dispose()` and hand async teardown to `unawaited(...)`; `Future<void> dispose() async` compiles and is the shape that
invites the bug.
Use `ProviderContainer` directly for simple Riverpod provider tests. The generated Riverpod `update()` method takes a callback: Use `ProviderContainer` directly for simple Riverpod provider tests. The generated Riverpod `update()` method takes a callback:
```dart ```dart
@@ -107,6 +407,53 @@ When testing freezed models with nested objects, always round-trip through `json
For async widgets, put visual cleanup in `finally` when the action may throw. Focused widget tests should cover success, For async widgets, put visual cleanup in `finally` when the action may throw. Focused widget tests should cover success,
failure, disposal, and any timer boundary that changes visible state. failure, disposal, and any timer boundary that changes visible state.
## Commit Messages
Subjects follow Conventional Commits and are enforced by the `commit-msg` hook in `.pre-commit-config.yaml`, which runs
`tool/check_commit_msg.sh`:
```text
<type>[(scope)][!]: <description>
```
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`.
- Scope is optional and lower case; use a comma to list several, as in `fix(core,android)`.
- `!` before the colon marks a breaking change.
- Descriptions start in lower case, omit the trailing period, and keep the whole subject within 100 characters.
Identifiers and acronyms keep their own casing, as in `fix(ui): AppBar text is truncated`.
- `Merge`/`Revert` subjects and `fixup!`/`squash!` commits are exempt.
- No `Co-authored-by` trailer crediting a coding agent, whatever that tool's own convention says. The history
records who owns the change, not which tool typed it; human co-authors are still fine. The hook rejects the
known agent identities.
Write what the change does, not that something changed: `perf(views): stop redoing per-frame work in build`, not
`Optimize more details`.
Install the hooks once with `pre-commit install --hook-type pre-commit --hook-type pre-push --hook-type commit-msg`.
### Changelog Trailers
`tool/changelog.dart` builds the user facing changelog from the commit history, so the trailers below are the copy that
ships to users. The subject stays the developer facing summary and is only the fallback.
```text
feat(profiles): support per-profile override script
Changelog: Per-profile override scripts
```
- `Changelog:` is the English entry. `Changelog: skip` drops the commit from the changelog entirely.
- The changelog is English only. Translation trailers were removed on purpose: they pushed release copy into the commit
history, so any `Changelog-<locale>:` or `Breaking-<locale>:` now fails the hook. Translate after the fact if ever
needed, not in the commit message.
- `Changelog-Type:` moves an entry into another group, for example to promote a `refactor` that users will notice. Valid
values are `breaking`, `feat`, `fix`, `perf`, `revert`.
- `BREAKING CHANGE:` is required whenever the subject carries `!`, and its text becomes the breaking entry. A `!` commit
therefore needs two lines of copy: the footer for the breaking entry and `Changelog:` for the normal one.
`feat`, `fix`, `perf`, `revert` and breaking commits are collected by default; every other type is dropped unless it
carries a `Changelog:` trailer. Commits missing a trailer reuse their subject, and the hook says so without blocking.
## Generated Code ## Generated Code
Do not manually edit generated files under: Do not manually edit generated files under:
@@ -118,3 +465,23 @@ Do not manually edit generated files under:
- `lib/l10n/intl/` - `lib/l10n/intl/`
After schema, model, or provider changes, run build generation and include focused tests when behavior changes. After schema, model, or provider changes, run build generation and include focused tests when behavior changes.
`lib/l10n/l10n.dart` is the one file that still imports `package:flutter/material.dart`.
`intl_utils` hardcodes that import in its own template, so regenerating rewrites it and
there is nothing to fix here; `test/lint/design_package_test.dart` exempts the generated
l10n paths for that reason. It is harmless because the file only needs `Locale`,
`BuildContext`, `Localizations` and `LocalizationsDelegate`, which the legacy library and
`material_ui` both re-export from the same `package:flutter/widgets.dart`. Everything a
human writes takes Material from `material_ui`; `cupertino_ui` is banned outright and
survives only as a transitive dependency of `material_ui`.
Strings live in `arb/intl_{en,zh_CN,ja,ru}.arb` — flat JSON, no `@` metadata. Add a key to all four, then regenerate with
`dart run intl_utils:generate`, which rewrites `lib/l10n/`. A key present in only some locales silently falls back to
English at runtime, so add the translation rather than leaving it out.
Some labels are not reached through the generated `AppLocalizations` getters at all. `Intl.message(<runtime string>)`
builds the key from an enum name or a stored string — `action_${HotAction.name}`, `${DynamicSchemeVariant.name}Scheme`,
`NavigationItem.description`. The analyzer sees nothing, and a failed lookup returns the key itself, so a stale key ships
as `routeMode_config` in the UI rather than throwing. `test/lint/dynamic_message_key_test.dart` expands those families
from the real enums and fails when a derived key is missing from any locale; every `Intl.message` site in `lib` must be
registered there, so a new dynamic key cannot be added without also declaring what builds it.
+1 -1
View File
@@ -7,7 +7,7 @@ description: Use when changing FlClash UI text, scanning for hardcoded Chinese s
## When To Use ## When To Use
Use this for localization work inside `/Users/Shared/follow/FlClash`, especially hardcoded UI text in `lib/`, ARB updates, missing translations, or generated `lib/l10n/` output that does not match source ARB values. Use 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.
Do not use this for README translation sync or manual edits to generated localization Dart. Do not use this for README translation sync or manual edits to generated localization Dart.
+62
View File
@@ -30,6 +30,68 @@ Use this for user-facing Flutter UI changes in `lib/`, including widgets, screen
flutter test test/widgets/ flutter test test/widgets/
``` ```
## Corner Radii
All corner radii come from `lib/common/shape.dart`. Never write a radius literal in a widget.
The scale is picked by the component's **shortest side**, not by what looks good in isolation. A radius that reads as
a soft card at 64 logical pixels tall reads as a pill at 24 and as a square at 400, so a single radius everywhere is
the wrong kind of consistency.
| Token | Value | Shortest side | Use |
| --- | --- | --- | --- |
| `none` | 0 | - | square edges, and the flat side of a grouped run |
| `xs` | 4 | inset blocks | tiles clipped inside an already rounded surface |
| `sm` | 8 | up to 48 | chips, thumbs, swatches, small square tiles |
| `md` | 16 | up to 64 | interactive chrome: inputs, menus, buttons, popups, FAB |
| `lg` | 20 | 64 to 180 | mid-size cards: dashboard tiles, proxy node cards |
| `xl` | 24 | full-bleed | anything spanning the whole width: list rows, grouped runs, group headers |
| `xxl` | 28 | over 200 | sheets, dialogs, full-screen containers |
| `full` | 1000 | - | pills and circles: tracks, indicators, progress, avatars |
`AppCorner.fit(shortestSide)` applies that table at runtime and is the right call whenever the size comes from a
`LayoutBuilder` or scales with text size. It snaps to the largest token that stays at or under one third of the
shortest side, which is the rule the table encodes.
- `AppCorner` holds the scale as `double`, for `radius:` on `CommonCard` and for arithmetic.
- `AppRadius` mirrors it as `BorderRadius`, plus `all`, `top`, and `vertical` builders.
- `AppShape` mirrors it as `RoundedSuperellipseBorder`, plus `full` (stadium), `circle`, `input`, and the
`all`/`top`/`vertical`/`of` builders.
- `ThemeData.withAppShapes` in `lib/application.dart` applies the scale to card, dialog, bottom sheet, snack bar,
chip, menu, input, FAB, navigation indicator, and progress themes. Do not restate those shapes at call sites; in
particular, leave `InputDecoration.border` unset so inputs inherit `AppShape.input`.
The three tiers between `md` and `xxl` each answer to a different size class, and the split is what keeps one radius
from being wrong for two of them.
`xl` is for a surface that spans the full width. A wide, short strip needs a larger corner than its height alone
suggests, or it stops reading as a card. Everything full-bleed shares this token and must stay on it:
`CommonSelectedListItem`, the profiles card, the outer corners of a grouped run in `DecorationListItem`
(`generateSectionV3`), and the proxy group header in `lib/views/proxies/list.dart`. The value is deliberately under
half the height of a standard row, so nothing gets clamped and every one of them renders at the identical radius no
matter which is taller. This is the one place the one-third rule is knowingly overshot; the width is what carries
it, and `fit()` is not used here.
`lg` is for the mid-size card that is not full-bleed: dashboard tiles and proxy node cards. It exists because `xl`
does not fit all of them. A proxy node card's height follows the user's `ProxyCardType` setting, and at `min` it is
only about 64 tall, which caps its radius at 21 — so `xl` would break on a setting the user can change at any time,
while `lg` clears every one of the three heights. The same value has room to spare on the smallest dashboard tile,
about 177x80, so both surfaces can hold one token instead of branching per size.
Outlined inputs use `AppInputBorder`, not `ShapedInputBorder` from `package:material_ui`. That one subtracts the
floating label's notch from the outline as a two-pixel band along the top edge, which assumes the label sits over a
flat run of border. Any radius from 8 up puts the corner curve under the notch instead, the subtraction takes the
corner with it, and the top edge is left drawn a pixel low. `AppInputBorder` clips the notch region away and paints
the full superellipse through it, so the corner arc is truncated exactly where the notch begins. That is what
Flutter's own `OutlineInputBorder` does by shortening the corner arc's sweep, which is why the framework border
survives large radii and the package one does not. The border needs no `contentPadding` compensation; leave Material's
defaults alone.
Nested radii are derived, never tokens. Concentric corners need `outer = inner + inset`, so name the inset and
compute the outer value: `_cardRadius` in `lib/widgets/popup.dart`, `_kCornerRadius` in `lib/widgets/tab.dart`, and
the selection ring in `lib/widgets/palette.dart` all do this. Adding an intermediate token to spell one of these out
is what makes a scale grow without bound.
## Pitfalls ## Pitfalls
- Do not introduce a new visual system for one screen. - Do not introduce a new visual system for one screen.
+5
View File
@@ -0,0 +1,5 @@
# Worktrees
Claude Code, Codex, and Gemini each create git worktrees. `tool/worktrees.sh list` shows every worktree with its
owning tool and whether it holds uncommitted work; `tool/worktrees.sh prune` removes the clean ones and keeps
dirty ones unless `--force` is passed. `.claude/worktrees/` is ignored by git; `.claude/settings.json` is tracked.
-14
View File
@@ -1,14 +0,0 @@
---
name: implementer
description: Use for scoped feature work, bug fixes, refactors, and focused test updates after the requirements are clear.
tools: Read, Grep, Glob, Bash, Edit, Write
model: sonnet
effort: high
---
Implement the requested change in the smallest appropriate scope. Follow the repository instructions in AGENTS.md and
the
linked .agents documentation, preserve existing lifecycle and serialization contracts, and keep unrelated working-tree
changes untouched. Trace the affected call path before editing, update focused tests when behavior changes, and report
the
files changed plus the verification actually run.
-11
View File
@@ -1,11 +0,0 @@
---
name: researcher
description: Use for fast read-only codebase exploration, call-path tracing, and locating relevant tests before implementation.
tools: Read, Grep, Glob
model: sonnet
effort: medium
---
Trace the actual call path instead of stopping at the first matching file. Identify the relevant symbols, lifecycle owner,
cross-platform boundary, existing tests, and focused verification commands. Do not modify files. Return concise,
evidence-based findings with file paths and line references when available.
-12
View File
@@ -1,12 +0,0 @@
---
name: reviewer
description: Use after code changes for correctness, regression, lifecycle, contract, and test review.
tools: Read, Grep, Glob, Bash
model: opus
effort: high
---
Review the current changes without editing files. Inspect the diff, trace the relevant call paths, and check platform
ownership, serialization or IPC contracts, error handling, and test coverage. Run focused read-only checks when useful.
Report actionable findings first, ordered by severity, with file paths and line references; state explicitly when no issue is
found and note any validation or platform coverage gaps.
+17
View File
@@ -0,0 +1,17 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PROJECT_DIR:-.}/tool/check_comment_density.sh\"",
"timeout": 15,
"statusMessage": "Checking comment density"
}
]
}
]
}
}
-12
View File
@@ -1,12 +0,0 @@
name = "implementer"
description = "Use for scoped feature work, bug fixes, refactors, and focused test updates after the requirements are clear."
model = "gpt-5.6-sol"
model_reasoning_effort = "high"
sandbox_mode = "workspace-write"
developer_instructions = """
Implement the requested change in the smallest appropriate scope. Follow the repository
instructions in AGENTS.md and the linked .agents documentation. Preserve existing
lifecycle and serialization contracts, and keep unrelated working-tree changes
untouched. Trace the affected call path before editing, update focused tests when
behavior changes, and report the files changed plus the verification actually run.
"""
-11
View File
@@ -1,11 +0,0 @@
name = "researcher"
description = "Use for fast read-only codebase exploration, call-path tracing, and locating relevant tests before implementation."
model = "gpt-5.6-luna"
model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
Trace the actual call path instead of stopping at the first matching file. Identify
the relevant symbols, lifecycle owner, cross-platform boundary, existing tests, and
focused verification commands. Do not modify files. Return concise, evidence-based
findings with file paths and line references when available.
"""
-13
View File
@@ -1,13 +0,0 @@
name = "reviewer"
description = "Use after code changes for correctness, regression, lifecycle, contract, and test review."
model = "gpt-5.6-luna"
model_reasoning_effort = "max"
sandbox_mode = "read-only"
developer_instructions = """
Review the current changes without editing files. Inspect the diff, trace the relevant
call paths, and check platform ownership, serialization or IPC contracts, error
handling, and test coverage. Run focused read-only checks when useful. Report
actionable findings first, ordered by severity, with file paths and line references;
state explicitly when no issue is found and note any validation or platform coverage
gaps.
"""
+8 -5
View File
@@ -1,5 +1,8 @@
[agents] [[hooks.PostToolUse]]
enabled = true matcher = "apply_patch|Bash"
max_concurrent_threads_per_session = 3
default_subagent_model = "gpt-5.6-luna" [[hooks.PostToolUse.hooks]]
default_subagent_reasoning_effort = "high" type = "command"
command = "bash tool/check_comment_density.sh"
timeout = 15
statusMessage = "Checking comment density"
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
previous_tag="${1:-}"
output="${2:-release.md}"
: > "$output"
append_range() {
local range="$1"
git log --no-merges --pretty=format:'%B' "$range" |
awk '!/Update changelog/ && NF {print "- " $0 "\n"}' >> "$output"
}
current_tag=""
while IFS= read -r next_tag; do
if [[ -n "$current_tag" ]]; then
[[ "$current_tag" == "$previous_tag" ]] && break
append_range "$next_tag..$current_tag"
fi
current_tag="$next_tag"
done < <(git tag --merged HEAD --sort=-creatordate)
if [[ -n "$current_tag" && "$current_tag" != "$previous_tag" ]]; then
append_range "$current_tag"
fi
@@ -1,66 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
generator="$script_dir/generate_release_notes.sh"
temp_dir="$(mktemp -d)"
trap 'rm -rf "$temp_dir"' EXIT
repo="$temp_dir/repo"
git init --quiet --initial-branch=main "$repo"
git -C "$repo" config user.email "release-notes-test@example.com"
git -C "$repo" config user.name "Release notes test"
commit() {
local message="$1"
local date="$2"
GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" \
git -C "$repo" commit --allow-empty --quiet --message "$message"
}
commit "Initial release" "2025-01-01T00:00:00Z"
git -C "$repo" tag v1.0.0
commit "First release change" "2025-01-02T00:00:00Z"
commit "Update changelog" "2025-01-03T00:00:00Z"
git -C "$repo" tag v1.1.0
git -C "$repo" branch feature
git -C "$repo" checkout --quiet feature
commit $'Feature branch change\n\nFeature detail' "2025-01-04T00:00:00Z"
git -C "$repo" checkout --quiet main
GIT_AUTHOR_DATE="2025-01-05T00:00:00Z" \
GIT_COMMITTER_DATE="2025-01-05T00:00:00Z" \
git -C "$repo" merge --no-ff --quiet --message "Merge feature" feature
git -C "$repo" tag v1.2.0
expected="$temp_dir/expected.md"
actual="$temp_dir/actual.md"
printf '%s\n' \
"- Feature branch change" \
"" \
"- Feature detail" \
"" \
"- First release change" \
"" > "$expected"
(
cd "$repo"
bash "$generator" v1.0.0 "$actual"
)
diff -u "$expected" "$actual"
printf '%s\n' "stale content" > "$actual"
(
cd "$repo"
bash "$generator" v1.2.0 "$actual"
)
[[ ! -s "$actual" ]]
(
cd "$repo"
bash "$generator" "" "$actual"
)
grep -q -- "- Initial release" "$actual"
+129 -97
View File
@@ -2,11 +2,18 @@ name: build
on: on:
push: push:
branches:
- '**'
tags: tags:
- 'v*' - 'v*'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }}
env: env:
IS_STABLE: ${{ !contains(github.ref, '-') }} IS_STABLE: ${{ !contains(github.ref, '-') }}
FLUTTER_VERSION: '3.44.4' FLUTTER_VERSION: '3.47.1'
jobs: jobs:
test: test:
@@ -17,10 +24,15 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
submodules: recursive submodules: recursive
fetch-depth: 0
- name: Test release notes generation - name: Test commit message hook
shell: bash shell: bash
run: bash .github/scripts/generate_release_notes_test.sh run: bash tool/check_commit_msg_test.sh
- name: Test comment density hook
shell: bash
run: bash tool/check_comment_density_test.sh
- name: Setup Flutter - name: Setup Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
@@ -32,41 +44,15 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: flutter pub get run: flutter pub get
- name: Check format
run: dart format --output=none --set-exit-if-changed lib test tool plugins setup.dart
- name: Analyze - name: Analyze
run: flutter analyze --no-fatal-infos run: flutter analyze --no-fatal-infos
- name: Run tests - name: Verify changelog
run: flutter test --reporter expanded if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
run: dart run tool/changelog.dart verify
- name: Validate local Flutter packages
shell: bash
run: |
for package in plugins/proxy plugins/wifi_ssid plugins/window_ext plugins/setup; do
(
cd "$package"
flutter pub get
flutter analyze --no-fatal-infos
)
done
- name: Run local plugin tests
shell: bash
run: |
(
cd plugins/proxy
flutter test --reporter expanded
)
(
cd plugins/wifi_ssid
flutter test --reporter expanded
)
- name: Validate setup build tool
working-directory: plugins/setup/buildkit/build_tool
run: |
dart pub get
dart analyze
dart test
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
@@ -74,13 +60,96 @@ jobs:
go-version: '1.26.4' go-version: '1.26.4'
cache-dependency-path: core/go.sum cache-dependency-path: core/go.sum
- name: Run tests
run: flutter test --reporter expanded --coverage
- name: Check coverage
run: dart run tool/check_coverage.dart coverage/lcov.info 75
- name: Validate local Flutter packages
shell: bash
run: bash tool/check_plugins.sh
- name: Validate setup hooks package
working-directory: plugins/setup/setup_hooks
run: |
dart pub get
dart analyze
dart test
- name: Validate Go core wrapper - name: Validate Go core wrapper
working-directory: core working-directory: core
shell: bash
env: env:
CGO_ENABLED: 0 CGO_ENABLED: 0
run: | run: |
go test . # `go test` succeeds silently when a package has no test files, so
# assert the wrapper actually ships tests before trusting the result.
if ! compgen -G '*_test.go' > /dev/null; then
echo 'No Go tests found in core/; the test step would pass vacuously.' >&2
exit 1
fi
test -z "$(gofmt -l ./*.go ./platform ./tun)" || {
gofmt -l ./*.go ./platform ./tun >&2
exit 1
}
go test -count=1 .
go vet . go vet .
# `go vet .` only reaches the root package. `platform` and `tun` are
# behind build constraints, so they need an explicit GOOS to compile
# at all; without this they ship unvetted.
GOOS=linux go vet ./platform
- name: Setup NDK
uses: nttld/setup-ndk@v1
id: core-ndk
with:
ndk-version: r28c
- name: Validate the Android core library
working-directory: core
shell: bash
env:
ANDROID_NDK_HOME: ${{ steps.core-ndk.outputs.ndk-path }}
run: |
# The step above pins CGO_ENABLED=0, which excludes every `android &&
# cgo` file: lib.go, bride.go, tun/ and platform/limit.go. The job
# that builds them needs the NDK and only runs on a `v*` tag, so
# without this the entire Android surface of the core first compiles
# at release time.
CC="$(echo "$ANDROID_NDK_HOME"/toolchains/llvm/prebuilt/*/bin/aarch64-linux-android21-clang)"
test -x "$CC" || { echo "NDK clang not found at $CC" >&2; exit 1; }
# Same tags the shipped library is built with; read from the build
# config at the repository root so the two cannot drift.
tags="$(sed -n 's/^tags: *//p' ../build_config.yaml)"
test -n "$tags" || { echo 'Could not read tags from build_config.yaml' >&2; exit 1; }
export CGO_ENABLED=1 GOOS=android GOARCH=arm64 CC
go vet "-tags=$tags" . ./tun ./platform
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Run Android unit tests
working-directory: android
shell: bash
run: |
# settings.gradle.kts resolves the Flutter tooling through local.properties,
# which only the Flutter build itself would otherwise generate.
{
echo "flutter.sdk=$FLUTTER_ROOT"
echo "sdk.dir=$ANDROID_HOME"
} > local.properties
wrapper="$FLUTTER_ROOT/bin/cache/artifacts/gradle_wrapper"
test -d "$wrapper" || flutter precache
cp -R "$wrapper/." .
chmod +x gradlew
# Modules are listed explicitly: the aggregate testDebugUnitTest task would
# also run third-party Flutter plugin suites. `:core` is omitted because its
# only class loads the native library through System.loadLibrary.
./gradlew :common:testDebugUnitTest :service:testDebugUnitTest :app:testDebugUnitTest
- name: Validate Rust components - name: Validate Rust components
run: | run: |
@@ -144,6 +213,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
submodules: recursive submodules: recursive
fetch-depth: 0
- name: Setup Android Signing - name: Setup Android Signing
if: startsWith(matrix.platform,'android') if: startsWith(matrix.platform,'android')
@@ -203,61 +273,6 @@ jobs:
path: ./dist path: ./dist
overwrite: true overwrite: true
changelog:
if: ${{ !contains(github.ref, '-') }}
permissions:
contents: write
runs-on: ubuntu-latest
needs: [ build ]
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: refs/heads/main
- name: Generate
run: |
last_ver=$(grep -m1 '^## ' CHANGELOG.md 2>/dev/null | sed 's/^## //')
mapfile -t tags < <(git tag --merged HEAD --sort=-creatordate)
temp="NEW_CHANGELOG.md"
: > "$temp"
for i in "${!tags[@]}"; do
curr="${tags[i]}"
[[ "$curr" == "$last_ver" ]] && break
prev="${tags[i+1]}"
range="${prev:+$prev..}$curr"
echo -e "## $curr\n" >> "$temp"
git log --no-merges --pretty=format:"%B" "$range" | \
awk '!/Update changelog/ && NF {print "- " $0 "\n"}' >> "$temp"
done
[ -f CHANGELOG.md ] && cat CHANGELOG.md >> "$temp"
mv "$temp" CHANGELOG.md
- name: Commit
run: |
git add CHANGELOG.md
if ! git diff --cached --quiet; then
echo "Commit pushing"
git config --local user.email "chen08209@gmail.com"
git config --local user.name "chen08209"
git commit -m "Update changelog"
if git push; then
echo "Push succeeded"
else
echo "Push failed"
exit 1
fi
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
upload: upload:
permissions: permissions:
contents: write contents: write
@@ -284,14 +299,31 @@ jobs:
pattern: artifact-* pattern: artifact-*
merge-multiple: true merge-multiple: true
- name: Generate release.md - name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install dependencies
run: flutter pub get
- name: Generate release notes
shell: bash shell: bash
env:
IS_STABLE: ${{ env.IS_STABLE }}
TAG: ${{ github.ref_name }}
run: | run: |
pre_tag=$( target=()
curl --silent "https://api.github.com/repos/chen08209/FlClash/releases/latest" | if [[ "$IS_STABLE" == 'true' ]]; then
sed -nE 's/.*"tag_name": "([^"]+)".*/\1/p' target=(--tag "$TAG")
) else
bash .github/scripts/generate_release_notes.sh "$pre_tag" release.md dart run tool/changelog.dart build --unreleased
fi
dart run tool/changelog.dart render release "${target[@]}" --out release.md
dart run tool/changelog.dart render telegram "${target[@]}" --out telegram.md
- name: Push to telegram - name: Push to telegram
env: env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
+2 -1
View File
@@ -43,6 +43,7 @@ app.*.map.json
# Claude Code - ignore user-specific settings only # Claude Code - ignore user-specific settings only
.claude/settings.local.json .claude/settings.local.json
.claude/worktrees/
# Documentation (generated/planning artifacts) # Documentation (generated/planning artifacts)
docs/ docs/
@@ -68,8 +69,8 @@ docs/
/macos/build/ /macos/build/
/env.json /env.json
/core_sha256.json /core_sha256.json
/core/*.exe
/plugins/rust_api/.claude/ /plugins/rust_api/.claude/
/issue-triage/
devtools_options.yaml devtools_options.yaml
# FVM Version Cache # FVM Version Cache
+12
View File
@@ -6,9 +6,21 @@ repos:
entry: dart format entry: dart format
language: system language: system
files: \.dart$ files: \.dart$
stages: [pre-commit]
- id: comment-density
name: comment density
entry: tool/check_comment_density.sh
language: system
files: \.(dart|kt|kts|swift|go|rs|java|cpp|cc|h|hpp|gradle|ya?ml)$
stages: [pre-commit]
- id: flutter-analyze - id: flutter-analyze
name: flutter analyze (pre-push) name: flutter analyze (pre-push)
entry: flutter analyze --no-fatal-infos entry: flutter analyze --no-fatal-infos
language: system language: system
pass_filenames: false pass_filenames: false
stages: [pre-push] stages: [pre-push]
- id: commit-msg-format
name: conventional commit message
entry: tool/check_commit_msg.sh
language: system
stages: [commit-msg]
+13 -7
View File
@@ -17,6 +17,7 @@ Read these only when the task touches their area:
local plugins. local plugins.
- [.agents/agent-config.md](.agents/agent-config.md): how to choose between `AGENTS.md`, `.agents`, skills, Codex config, - [.agents/agent-config.md](.agents/agent-config.md): how to choose between `AGENTS.md`, `.agents`, skills, Codex config,
command rules, and hooks. command rules, and hooks.
- [.agents/worktrees.md](.agents/worktrees.md): worktree hygiene across Claude Code, Codex, and Gemini.
- [.agents/skills.md](.agents/skills.md): index of repo-scoped skills in `.agents/skills/`. - [.agents/skills.md](.agents/skills.md): index of repo-scoped skills in `.agents/skills/`.
## Highest Priority Rules ## Highest Priority Rules
@@ -24,12 +25,15 @@ Read these only when the task touches their area:
- When the user explicitly requests a scoped, low-risk change, inspect the relevant context and implement it directly. - When the user explicitly requests a scoped, low-risk change, inspect the relevant context and implement it directly.
Do not require brainstorming, design documents, implementation plans, multiple-option proposals, or repeated confirmation. Do not require brainstorming, design documents, implementation plans, multiple-option proposals, or repeated confirmation.
Ask only when material ambiguity, destructive impact, additional authority, or scope expansion could change the result. Ask only when material ambiguity, destructive impact, additional authority, or scope expansion could change the result.
- Do not add code or configuration comments unless the user explicitly asks for comments. This includes explanatory, - Comments are allowed and sometimes necessary; excess is the problem. A comment must carry something the code cannot
narrative, TODO, and documentation comments. Never annotate line by line; comments belong only at the few key points say — a non-obvious constraint, an upstream behavior being worked around, a reason a reader would otherwise get
that cannot be understood without one, and there you must propose the exact text and wait for approval. Delete wrong. Never restate what the code does, narrate the change you just made, record what the code used to be, or
commented-out code and stale notes whenever you touch the surrounding code. Put assertable behavior in a test, annotate step by step; a block that seems to need a comment per line needs better names or a smaller decomposition.
repository-wide invariants in `.agents/`, and keep a comment only for a fact that is local to one call site. Keep density near the repository's own: healthy changes here sit under 4%, and a `comment-density` gate fails a file
See [.agents/rules.md](.agents/rules.md) for the full policy. whose added lines exceed 5% standalone comments. Delete commented-out code and stale notes in files you already
touch. Preserve
`// ignore:`-style directives, license headers, codegen markers, and vendored upstream comments. See
[.agents/rules.md](.agents/rules.md) for what belongs in a test or in `.agents/` instead.
- Use `flutter test`, not `dart test`, because models pull in Flutter types. - Use `flutter test`, not `dart test`, because models pull in Flutter types.
- Run code generation after modifying models, providers, or database schema. - Run code generation after modifying models, providers, or database schema.
- Do not manually edit generated files. - Do not manually edit generated files.
@@ -39,7 +43,9 @@ Read these only when the task touches their area:
- Keep start/stop/restart paths latest-intent-safe. Flutter-to-Android service commands are deliberately optimistic, while - Keep start/stop/restart paths latest-intent-safe. Flutter-to-Android service commands are deliberately optimistic, while
native state serializes the actual work; desktop lifecycle results distinguish applied, coalesced, and superseded native state serializes the actual work; desktop lifecycle results distinguish applied, coalesced, and superseded
requests. requests.
- Follow `analysis_options.yaml`, especially single quotes, trailing commas, `child:` last, no `print()`, const/final - Never add a `Co-authored-by` trailer crediting a coding agent to a commit, even when your own tooling tells you to.
The `commit-msg` hook rejects it; see [.agents/rules.md](.agents/rules.md) for the rest of the commit rules.
- Follow `lint_options.yaml` (included by every `analysis_options.yaml`), especially single quotes, trailing commas, `child:` last, no `print()`, const/final
preferences, and declared return types. preferences, and declared return types.
- For CI parity, verify with `flutter pub get`, `flutter analyze --no-fatal-infos`, and - For CI parity, verify with `flutter pub get`, `flutter analyze --no-fatal-infos`, and
`flutter test --reporter expanded` when practical. `flutter test --reporter expanded` when practical.
+105 -416
View File
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -1,4 +1 @@
# CLAUDE.md @AGENTS.md
This repository uses [AGENTS.md](AGENTS.md) as the canonical agent entry point.
Read that file first, then follow the `.agents/` references it routes to.
+5 -28
View File
@@ -1,35 +1,12 @@
SHELL := /bin/bash SHELL := /bin/bash
PLATFORM ?= macos .PHONY: help submodules
BUILDKIT := plugins/setup/buildkit/run_build_tool.sh
ARCH_ARG := $(if $(ARCH),--arch $(ARCH),)
TARGET_PLATFORM_ARG := $(if $(TARGET_PLATFORM),--target-platform $(TARGET_PLATFORM),)
FORCE_ARG := $(if $(filter 1 true yes,$(FORCE)),--force,)
.PHONY: help submodules core core-macos core-linux core-windows core-android
help: help:
@echo 'make core # build macOS core by default' @echo 'make submodules # update git submodules (Clash.Meta core sources)'
@echo 'make core PLATFORM=linux ARCH=amd64' @echo ''
@echo 'make core-macos ARCH=arm64' @echo 'The Go core and Rust helper build automatically through the setup build'
@echo 'make core-android ARCH=arm64' @echo 'hook during flutter build; see .agents/commands.md.'
@echo 'make core-android TARGET_PLATFORM=android-arm64'
@echo 'make core-macos FORCE=1 # bypass setup build cache'
submodules: submodules:
git submodule update --init --recursive git submodule update --init --recursive
core:
bash $(BUILDKIT) $(PLATFORM) $(ARCH_ARG) $(TARGET_PLATFORM_ARG) $(FORCE_ARG)
core-macos:
$(MAKE) core PLATFORM=macos
core-linux:
$(MAKE) core PLATFORM=linux
core-windows:
$(MAKE) core PLATFORM=windows
core-android:
$(MAKE) core PLATFORM=android
+8 -35
View File
@@ -1,43 +1,16 @@
include: package:flutter_lints/flutter.yaml include: lint_options.yaml
analyzer: analyzer:
exclude: exclude:
- build/** - build/**
- lib/l10n/intl/** - lib/l10n/intl/**
- lib/**/generated/** - lib/**/generated/**
- plugins/** - plugins/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
errors: errors:
invalid_annotation_target: ignore invalid_annotation_target: ignore
linter:
rules:
# Style
prefer_single_quotes: true
require_trailing_commas: true
sort_child_properties_last: true
# Avoid unnecessary code
unnecessary_new: true
unnecessary_this: true
unnecessary_const: true
avoid_print: true
avoid_unnecessary_containers: true
sized_box_for_whitespace: true
# Prefer best practices
prefer_const_constructors: true
prefer_const_declarations: true
prefer_final_locals: true
prefer_final_in_for_each: true
prefer_is_empty: true
prefer_is_not_empty: true
prefer_interpolation_to_compose_strings: true
# Type safety
always_declare_return_types: true
annotate_overrides: true
use_key_in_widget_constructors: true
no_leading_underscores_for_local_identifiers: true
# Async
use_build_context_synchronously: true
unnecessary_await_in_return: true
+8
View File
@@ -0,0 +1,8 @@
tags: with_gvisor
go_ldflags: "-w -s"
core_dir: core
core_name: FlClashCore
lib_name: libclash
output_dir: libclash
helper_dir: services/helper
helper_name: FlClashHelperService
+4
View File
@@ -0,0 +1,4 @@
{
"schemaVersion": 2,
"versions": []
}
+43
View File
@@ -0,0 +1,43 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
prefer_single_quotes: true
require_trailing_commas: true
sort_child_properties_last: true
unnecessary_new: true
unnecessary_this: true
unnecessary_const: true
avoid_print: true
avoid_unnecessary_containers: true
sized_box_for_whitespace: true
prefer_const_constructors: true
prefer_const_declarations: true
prefer_final_locals: true
prefer_final_in_for_each: true
prefer_is_empty: true
prefer_is_not_empty: true
prefer_interpolation_to_compose_strings: true
always_declare_return_types: true
annotate_overrides: true
use_key_in_widget_constructors: true
no_leading_underscores_for_local_identifiers: true
use_build_context_synchronously: true
unnecessary_await_in_return: true
cancel_subscriptions: true
close_sinks: true
only_throw_errors: true
test_types_in_equals: true
avoid_type_to_string: true
literal_only_boolean_expressions: true
no_adjacent_strings_in_list: true
empty_statements: true
throw_in_finally: true
control_flow_in_finally: true
unawaited_futures: true
+40 -13
View File
@@ -1,5 +1,8 @@
import os import html
import json import json
import os
import sys
import requests import requests
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
@@ -12,7 +15,10 @@ CHAT_ID = "@FlClash"
API_URL = f"http://localhost:8081/bot{TELEGRAM_BOT_TOKEN}/sendMediaGroup" API_URL = f"http://localhost:8081/bot{TELEGRAM_BOT_TOKEN}/sendMediaGroup"
DIST_DIR = os.path.join(os.getcwd(), "dist") DIST_DIR = os.path.join(os.getcwd(), "dist")
release = os.path.join(os.getcwd(), "release.md") # Rendered by `tool/changelog.dart render telegram`: plain bullets, already
# truncated to fit the caption limit. release.md carries the download table and
# would blow past that limit.
release = os.path.join(os.getcwd(), "telegram.md")
text = "" text = ""
@@ -42,7 +48,7 @@ for file in os.listdir(DIST_DIR):
i += 1 i += 1
if TAG: if TAG:
text += f"\n**{TAG}**\n" text += f"\n<b>{html.escape(TAG)}</b>\n"
if IS_STABLE: if IS_STABLE:
text += f"\nhttps://github.com/chen08209/FlClash/releases/tag/{TAG}\n" text += f"\nhttps://github.com/chen08209/FlClash/releases/tag/{TAG}\n"
@@ -57,15 +63,36 @@ if os.path.exists(release):
if media: if media:
media[-1]["caption"] = text media[-1]["caption"] = text
media[-1]["parse_mode"] = "Markdown" # HTML, not Markdown: the caption carries changelog entries written by
# whoever wrote the commit, and HTML is the one Telegram parse mode with a
# defined escape for them. `tool/changelog.dart render telegram` emits the
# matching markup.
media[-1]["parse_mode"] = "HTML"
response = requests.post( try:
API_URL, response = requests.post(
data={ API_URL,
"chat_id": CHAT_ID, data={
"media": json.dumps(media) "chat_id": CHAT_ID,
}, "media": json.dumps(media)
files=files },
) files=files,
timeout=300
)
finally:
for handle in files.values():
handle.close()
print("Response JSON:", response.json()) # A rejected caption comes back as a 400 with a description, not an exception.
# Without this the release job stays green while the announcement never posted.
try:
payload = response.json()
except ValueError:
payload = None
print("Response JSON:", payload if payload is not None else response.text)
if not response.ok or not (payload or {}).get("ok"):
description = (payload or {}).get("description", response.text)
print(f"Telegram rejected the release post: {description}", file=sys.stderr)
sys.exit(1)
+71 -42
View File
@@ -3,10 +3,11 @@ import 'dart:io';
import 'package:args/args.dart'; import 'package:args/args.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:yaml/yaml.dart';
const _allTargets = <String, String>{ const _allTargets = <String, String>{
'android': 'apk', 'android': 'apk',
'linux': 'deb', // appimage + rpm added for amd64 only 'linux': 'deb,appimage,rpm',
'macos': 'dmg', 'macos': 'dmg',
'windows': 'exe,zip', 'windows': 'exe,zip',
}; };
@@ -53,8 +54,20 @@ Future<void> main(List<String> args) async {
final env = results['env'] as String; final env = results['env'] as String;
final rootDir = Directory.current.path; final rootDir = Directory.current.path;
final skipped = packagesNotBuildingAssets(
File(p.join(rootDir, 'pubspec.yaml')).readAsStringSync(),
);
if (skipped.isNotEmpty) {
stderr.writeln(
'pubspec.yaml sets hooks.user_defines.<package>.build_assets: false '
'for ${skipped.join(', ')}; a package built this way would ship '
'whatever is left in libclash/ and no Rust library. '
'Restore "build_assets: true".',
);
exit(1);
}
final arch = _detectArch(); final arch = _detectArch();
final targets = _getTargets(platform, arch, results['targets']); final targets = createPackageTargets(platform, results['targets']);
final androidArch = results['arch'] as String?; final androidArch = results['arch'] as String?;
final verbose = results['verbose'] as bool; final verbose = results['verbose'] as bool;
@@ -115,10 +128,20 @@ Map<String, String> createBuildEnvironment(String env) {
return {'APP_ENV': env}; return {'APP_ENV': env};
} }
String _getTargets(String platform, String arch, String? customTargets) { /// Packages whose build hook `pubspec.yaml` turns into a no-op.
if (customTargets != null) return customTargets; List<String> packagesNotBuildingAssets(String pubspec) {
if (platform == 'linux' && arch == 'amd64') return 'deb,appimage,rpm'; final document = loadYaml(pubspec);
return _allTargets[platform]!; if (document is! Map) return const [];
final defines = (document['hooks'] as Map?)?['user_defines'];
if (defines is! Map) return const [];
return [
for (final MapEntry(:key, :value) in defines.entries)
if (value is Map && value['build_assets'] == false) key.toString(),
]..sort();
}
String createPackageTargets(String platform, String? customTargets) {
return customTargets ?? _allTargets[platform]!;
} }
void _showHelp(ArgParser parser) { void _showHelp(ArgParser parser) {
@@ -152,7 +175,7 @@ Future<int> _package(
descriptionArgs.addAll(['--description', arch]); descriptionArgs.addAll(['--description', arch]);
} }
final depExit = await _ensureDependencies(platform, arch); final depExit = await _ensureDependencies(platform);
if (depExit != 0) return depExit; if (depExit != 0) return depExit;
final activateResult = await Process.run('dart', [ final activateResult = await Process.run('dart', [
@@ -163,7 +186,7 @@ Future<int> _package(
'git', 'git',
'https://github.com/chen08209/flutter_distributor.git', 'https://github.com/chen08209/flutter_distributor.git',
'--git-ref', '--git-ref',
'FlClash', 'v0.6.11-flclash.2',
'--git-path', '--git-path',
'packages/flutter_distributor', 'packages/flutter_distributor',
]); ]);
@@ -188,7 +211,6 @@ Future<int> _package(
...descriptionArgs, ...descriptionArgs,
], ],
includeParentEnvironment: true, includeParentEnvironment: true,
environment: {'ANDROID_ARCH': ?androidArch},
runInShell: Platform.isWindows, runInShell: Platform.isWindows,
); );
@@ -221,12 +243,12 @@ Future<bool> _hasCommand(String cmd) async {
return result.exitCode == 0; return result.exitCode == 0;
} }
Future<int> _ensureDependencies(String platform, String arch) async { Future<int> _ensureDependencies(String platform) async {
switch (platform) { switch (platform) {
case 'macos': case 'macos':
return _ensureMacosDependencies(); return _ensureMacosDependencies();
case 'linux': case 'linux':
return _ensureLinuxDependencies(arch); return _ensureLinuxDependencies();
default: default:
return 0; return 0;
} }
@@ -245,20 +267,15 @@ Future<int> _ensureMacosDependencies() async {
return result.exitCode; return result.exitCode;
} }
Future<int> _ensureLinuxDependencies(String arch) async { Future<int> _ensureLinuxDependencies() async {
final pkgGroups = <List<String>>[ const pkgGroups = <List<String>>[
['ninja-build', 'libgtk-3-dev'], ['ninja-build', 'libgtk-3-dev'],
['libayatana-appindicator3-dev'], ['libayatana-appindicator3-dev'],
['libkeybinder-3.0-dev'],
['libsecret-1-dev'], ['libsecret-1-dev'],
['locate'], ['locate'],
['rpm', 'patchelf'],
['libfuse2'],
]; ];
if (arch == 'amd64') {
pkgGroups.addAll([
['rpm', 'patchelf'],
['libfuse2'],
]);
}
final missingGroups = <List<String>>[]; final missingGroups = <List<String>>[];
for (final group in pkgGroups) { for (final group in pkgGroups) {
@@ -298,33 +315,45 @@ Future<int> _ensureLinuxDependencies(String arch) async {
} }
} }
if (arch == 'amd64') { const appimagetool = '/usr/local/bin/appimagetool';
const appimagetool = '/usr/local/bin/appimagetool'; if (File(appimagetool).existsSync()) {
if (File(appimagetool).existsSync()) { stdout.writeln('appimagetool already installed, skipping.');
stdout.writeln('appimagetool already installed, skipping.'); return 0;
return 0;
}
stdout.writeln('Downloading appimagetool...');
final downloadName = arch == 'amd64' ? 'x86_64' : 'aarch64';
final dlResult = await Process.run('wget', [
'-O',
appimagetool,
'https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-$downloadName.AppImage',
]);
if (dlResult.exitCode != 0) {
stderr.write(dlResult.stderr);
return dlResult.exitCode;
}
await Process.run('chmod', ['+x', appimagetool]);
} }
stdout.writeln('Downloading appimagetool...');
final downloadName =
'appimagetool-${appImageToolArch(_detectArch())}.AppImage';
final dlResult = await Process.run('wget', [
'-O',
appimagetool,
'https://github.com/AppImage/AppImageKit/releases/download/continuous/$downloadName',
]);
if (dlResult.exitCode != 0) {
stderr.write(dlResult.stderr);
return dlResult.exitCode;
}
await Process.run('chmod', ['+x', appimagetool]);
return 0; return 0;
} }
String appImageToolArch(String arch) {
return arch == 'arm64' ? 'aarch64' : 'x86_64';
}
/// Ubuntu 24.04 ships libfuse2 under its time64 name, which `dpkg -s libfuse2` cannot see.
const _debianPackageAliases = <String, List<String>>{
'libfuse2': ['libfuse2t64'],
};
Future<bool> _isDebianPackageInstalled(String pkg) async { Future<bool> _isDebianPackageInstalled(String pkg) async {
final result = await Process.run('dpkg', ['-s', pkg]); for (final name in [pkg, ...?_debianPackageAliases[pkg]]) {
return result.exitCode == 0 && final result = await Process.run('dpkg', ['-s', name]);
(result.stdout as String).contains('Status: install ok installed'); if (result.exitCode == 0 &&
(result.stdout as String).contains('Status: install ok installed')) {
return true;
}
}
return false;
} }
Future<bool> _areDebianPackagesInstalled(List<String> pkgs) async { Future<bool> _areDebianPackagesInstalled(List<String> pkgs) async {
+94
View File
@@ -0,0 +1,94 @@
import 'dart:io';
import 'package:test/test.dart';
const _entryPoints = ['lib/main.dart'];
/// Types, and the top level names a file publishes alongside them — the
/// `final appPath = AppPath()` singleton pattern is how most of `lib/common`
/// is actually consumed.
final _declaration = RegExp(
r'^(?:abstract |sealed |final |base |mixin )*'
r'(?:(?:class|enum|mixin)\s+([A-Za-z]\w*)'
r'|(?:final|const)\s+(?:[\w<>,\s\[\]?]+\s+)?([a-z]\w*)\s*=)',
multiLine: true,
);
Iterable<File> _dartFiles({required bool includeGenerated}) sync* {
for (final root in ['lib', 'test', 'tool', 'plugins']) {
final directory = Directory(root);
if (!directory.existsSync()) {
fail('$root no longer exists; update this test.');
}
for (final entity in directory.listSync(recursive: true)) {
if (entity is! File || !entity.path.endsWith('.dart')) continue;
final generated =
entity.path.endsWith('.g.dart') ||
entity.path.endsWith('.freezed.dart') ||
entity.path.contains('/generated/');
if (generated && !includeGenerated) continue;
yield entity;
}
}
}
bool _isBarrel(String source) {
final lines = source
.split('\n')
.map((line) => line.trim())
.where((line) => line.isNotEmpty && !line.startsWith('//'));
return lines.isNotEmpty &&
lines.every(
(line) => line.startsWith('export ') || line.startsWith('import '),
);
}
void main() {
test('every file under lib declares something used outside itself', () {
// Generated code counts as a consumer but never as a declarer: a riverpod
// notifier is reached through the provider its annotation generates.
final consumers = {
for (final file in _dartFiles(includeGenerated: true))
file.path: file.readAsStringSync(),
};
final sources = {
for (final file in _dartFiles(includeGenerated: false))
file.path: consumers[file.path]!,
};
final barrels = {
for (final MapEntry(key: path, value: source) in sources.entries)
if (_isBarrel(source)) path,
};
final orphans = <String>[];
for (final MapEntry(key: path, value: source) in sources.entries) {
if (!path.startsWith('lib/')) continue;
if (barrels.contains(path) || _entryPoints.contains(path)) continue;
if (path.startsWith('lib/l10n/')) continue;
// Extensions and typedefs are reached through the types they attach to,
// never by name, so a file that publishes only those cannot be measured
// this way.
final names = _declaration
.allMatches(source)
.map((match) => match.group(1) ?? match.group(2)!)
.toSet();
if (names.isEmpty) continue;
final used = consumers.entries.any((entry) {
if (entry.key == path || barrels.contains(entry.key)) return false;
return names.any((name) => RegExp('\\b$name\\b').hasMatch(entry.value));
});
if (!used) {
orphans.add(
'$path — publishes ${names.join(', ')}, none of which is referenced '
'anywhere else. A barrel export keeps it compiling; it is still dead.',
);
}
}
expect(orphans, isEmpty, reason: orphans.join('\n'));
});
}
+63
View File
@@ -0,0 +1,63 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
const _roots = ['lib', 'test', 'tool', 'plugins'];
const _forbiddenImports = <String, String>{
'package:cupertino_ui/':
'the app is Material only; take widgets-layer types from '
'package:flutter/widgets.dart instead',
'package:flutter/material.dart':
'Material comes from package:material_ui/material_ui.dart',
'package:flutter/cupertino.dart':
'the app is Material only; take widgets-layer types from '
'package:flutter/widgets.dart instead',
};
const _generatedL10n = ['lib/l10n/l10n.dart', 'lib/l10n/intl/'];
bool _isGenerated(String path) {
return path.contains('/generated/') ||
path.endsWith('.g.dart') ||
path.endsWith('.freezed.dart') ||
_generatedL10n.any(path.startsWith);
}
void main() {
test('material_ui is the only design library the project imports', () {
final offenders = <String>[];
for (final root in _roots) {
final directory = Directory(root);
if (!directory.existsSync()) {
fail('$root no longer exists; update _roots.');
}
for (final entity in directory.listSync(recursive: true)) {
if (entity is! File || !entity.path.endsWith('.dart')) {
continue;
}
final relative = p.relative(entity.path);
if (_isGenerated(relative)) {
continue;
}
final lines = entity.readAsLinesSync();
for (var i = 0; i < lines.length; i++) {
final line = lines[i].trimLeft();
if (!line.startsWith('import ') && !line.startsWith('export ')) {
continue;
}
for (final entry in _forbiddenImports.entries) {
if (line.contains("'${entry.key}") ||
line.contains('"${entry.key}')) {
offenders.add('$relative:${i + 1} — ${entry.value}');
}
}
}
}
}
expect(offenders, isEmpty, reason: offenders.join('\n'));
});
}
+111
View File
@@ -0,0 +1,111 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
const _disposableTypes = {
'AnimationController',
'FocusNode',
'PageController',
'ScrollController',
'StreamController',
'TabController',
'TextEditingController',
'ValueNotifier',
};
/// Fields the analyzer would flag that are deliberately never released, keyed by
/// `<path>#<field>`. Everything here has to outlive the process, not a widget.
const _allowed = {
// `CoreEventManager` is a private-constructor singleton that fans core events
// out for the whole run; closing its controller would end event delivery.
'lib/core/event.dart#_controller',
};
final _declaration = RegExp(
r'^\s+(?:late\s+)?final\s+(?:[A-Za-z][\w<>,\s?]*\s+)?(_?[A-Za-z]\w*)\s*=\s*'
r'(?:[A-Za-z]\w*)?'
'(${_disposableTypes.join('|')})'
r'\b',
);
/// A `late` field declared without an initialiser, which the pattern above
/// cannot see because there is no constructor call on the line. These are
/// built in `initState` and owned just the same — `pages/editor.dart` kept a
/// `late TextEditingController` alive for the process this way.
final _lateDeclaration = RegExp(
'^\\s+late\\s+(?:final\\s+)?(${_disposableTypes.join('|')})'
r'(?:<[\w<>,\s?]*>)?\??\s+(_?[A-Za-z]\w*)\s*;',
);
bool _isGenerated(String path) {
return path.contains('/generated/') ||
path.endsWith('.g.dart') ||
path.endsWith('.freezed.dart');
}
void main() {
test('every self-created disposable field is released in the same file', () {
final root = Directory('lib');
final offenders = <String>[];
for (final entity in root.listSync(recursive: true)) {
if (entity is! File || !entity.path.endsWith('.dart')) {
continue;
}
final relative = p.relative(entity.path);
if (_isGenerated(relative)) {
continue;
}
final source = entity.readAsStringSync();
final lines = source.split('\n');
for (var i = 0; i < lines.length; i++) {
final match = _declaration.firstMatch(lines[i]);
final lateMatch = match == null
? _lateDeclaration.firstMatch(lines[i])
: null;
if (match == null && lateMatch == null) {
continue;
}
final field = match?.group(1) ?? lateMatch!.group(2)!;
final type = match?.group(2) ?? lateMatch!.group(1)!;
if (_allowed.contains('$relative#$field')) {
continue;
}
final released =
source.contains('$field.dispose()') ||
source.contains('$field.close()');
if (!released) {
offenders.add('$relative:${i + 1} $field ($type)');
}
}
}
expect(
offenders,
isEmpty,
reason:
'These fields are constructed by their owner but never disposed or '
'closed. Release them in `dispose()`, or add the declaration to '
'`_allowed` with the reason it outlives its owner.\n'
'${offenders.join('\n')}',
);
});
test('the allow list has no stale entries', () {
for (final entry in _allowed) {
final parts = entry.split('#');
final file = File(parts.first);
expect(
file.existsSync(),
isTrue,
reason: '$entry names a file that no longer exists',
);
expect(
file.readAsStringSync(),
contains(parts.last),
reason: '$entry names a field that no longer exists',
);
}
});
}
+80
View File
@@ -0,0 +1,80 @@
import 'dart:convert';
import 'dart:io';
import 'package:fl_clash/common/l10n_labels.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:flutter_test/flutter_test.dart';
const _arbDir = 'arb';
const _libDir = 'lib';
Map<String, Set<String>> _arbKeys() {
final result = <String, Set<String>>{};
for (final entity in Directory(_arbDir).listSync()) {
if (entity is! File || !entity.path.endsWith('.arb')) continue;
final decoded =
jsonDecode(entity.readAsStringSync()) as Map<String, Object?>;
result[entity.uri.pathSegments.last] = decoded.keys
.where((key) => !key.startsWith('@'))
.toSet();
}
return result;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('every locale carries the same keys', () {
final byFile = _arbKeys();
expect(byFile, isNotEmpty, reason: 'no .arb files were read from $_arbDir');
final reference = byFile['intl_en.arb'];
expect(reference, isNotNull, reason: 'arb/intl_en.arb is the reference');
for (final entry in byFile.entries) {
expect(
entry.value,
reference,
reason:
'${entry.key} does not carry the same keys as intl_en.arb. A key '
'present in only one locale renders as the raw key elsewhere.',
);
}
});
test('no Intl.message call builds a key at runtime', () {
final offenders = <String>[];
for (final entity in Directory(_libDir).listSync(recursive: true)) {
if (entity is! File || !entity.path.endsWith('.dart')) continue;
final path = entity.path;
if (path.contains('/generated/') || path.contains('/l10n/')) continue;
if (entity.readAsStringSync().contains('Intl.message(')) {
offenders.add(path);
}
}
expect(
offenders,
isEmpty,
reason:
'Intl.message(<runtime string>) is invisible to the analyzer and '
'returns its argument unchanged when the lookup fails, shipping the '
'raw key to the UI. Add an exhaustive-switch label to '
'lib/common/l10n_labels.dart instead:\n ${offenders.join('\n ')}',
);
});
test('every supported locale has a translated display name', () async {
for (final locale in AppLocalizations.delegate.supportedLocales) {
await AppLocalizations.load(locale);
expect(
locale.label,
isNot(locale.toString()),
reason:
'LocaleL10n.label fell back to the raw locale code for $locale. '
'Add the locale to the switch in lib/common/l10n_labels.dart and '
'a display-name key to every .arb file.',
);
}
});
}
+78
View File
@@ -0,0 +1,78 @@
import 'dart:io';
import 'package:test/test.dart';
/// The one button that carries its label on an enclosing [Tooltip] instead of
/// its own `tooltip:`. Nesting a second tooltip inside would fight it.
const _wrappedInTooltip = 'lib/views/dashboard/widgets/core_status_button.dart';
final _iconButton = RegExp(
r'\bIconButton(?:\.(?:filled|filledTonal|outlined))?\(',
);
Iterable<File> _dartFilesIn(String root) sync* {
final directory = Directory(root);
if (!directory.existsSync()) {
fail('$root no longer exists; update this test.');
}
for (final entity in directory.listSync(recursive: true)) {
if (entity is File &&
entity.path.endsWith('.dart') &&
!entity.path.endsWith('.g.dart') &&
!entity.path.endsWith('.freezed.dart') &&
!entity.path.contains('/generated/')) {
yield entity;
}
}
}
String _arguments(String source, int start) {
var depth = 1;
var index = start;
while (index < source.length && depth > 0) {
if (source[index] == '(') depth++;
if (source[index] == ')') depth--;
index++;
}
return source.substring(start, index - 1);
}
void main() {
test('every icon-only IconButton carries a tooltip', () {
final unlabelled = <String>[];
for (final file in _dartFilesIn('lib')) {
final source = file.readAsStringSync();
if (file.path == _wrappedInTooltip) continue;
for (final match in _iconButton.allMatches(source)) {
final arguments = _arguments(source, match.end);
if (RegExp(r'\btooltip\s*:').hasMatch(arguments)) continue;
// An icon slot holding text is already its own visible label.
if (RegExp(r'icon:\s*Text\(').hasMatch(arguments)) continue;
final line = '\n'.allMatches(source.substring(0, match.start)).length;
unlabelled.add(
'${file.path}:${line + 1} — an icon has no accessible name, so '
'TalkBack and VoiceOver announce nothing and the desktop build shows '
'no hover hint.',
);
}
}
expect(unlabelled, isEmpty, reason: unlabelled.join('\n'));
});
test('the exempted button still gets its label from an enclosing Tooltip', () {
final source = File(_wrappedInTooltip).readAsStringSync();
expect(
source,
contains('Tooltip('),
reason:
'$_wrappedInTooltip is exempted from the tooltip rule because an '
'enclosing Tooltip labels it. That wrapper is gone; either restore it '
'or give the button its own tooltip and drop the exemption.',
);
});
}
+160
View File
@@ -0,0 +1,160 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
const _platformPackages = [
'tray',
'window_manager',
'launch_at_startup',
'screen_retriever',
];
const _platformModules = [
'lib/common/tray.dart',
'lib/common/window.dart',
'lib/common/launch.dart',
'lib/common/system_dns.dart',
'lib/common/permission.dart',
];
final _platformImport = RegExp(
"^import 'package:(${_platformPackages.join('|')})/",
multiLine: true,
);
Set<String> _closureOfCommonBarrel() {
String resolve(String uri, String from) {
if (uri.startsWith('package:fl_clash/')) {
return 'lib/${uri.substring('package:fl_clash/'.length)}';
}
if (uri.startsWith('dart:') || uri.startsWith('package:')) return uri;
return p.normalize(p.join(p.dirname(from), uri));
}
final directive = RegExp(
r'''^\s*(?:import|export|part)\s+['"]([^'"]+)['"]''',
multiLine: true,
);
final reached = <String>{'lib/common/common.dart'};
final queue = <String>[...reached];
while (queue.isNotEmpty) {
final current = queue.removeLast();
final file = File(current);
if (!file.existsSync()) continue;
for (final match in directive.allMatches(file.readAsStringSync())) {
final next = resolve(match.group(1)!, current);
if (!reached.add(next)) continue;
queue.add(next);
}
}
return reached;
}
Iterable<File> _dartFilesIn(String root) sync* {
final directory = Directory(root);
if (!directory.existsSync()) {
fail('$root no longer exists; update this test.');
}
for (final entity in directory.listSync(recursive: true)) {
if (entity is File &&
entity.path.endsWith('.dart') &&
!entity.path.contains('/generated/')) {
yield entity;
}
}
}
void main() {
test('the common barrel never re-exports a platform module', () {
final exports = File('lib/common/common.dart').readAsLinesSync();
final leaked = <String>[];
for (final module in _platformModules) {
final line = "export '${p.basename(module)}';";
if (exports.contains(line)) {
leaked.add(
'$line — importing the barrel would pull the platform package into '
'every one of its consumers',
);
}
}
expect(leaked, isEmpty, reason: leaked.join('\n'));
});
test('only the designated modules reach a desktop platform package', () {
final offenders = <String>[];
for (final root in ['lib/common', 'lib/enum', 'lib/models']) {
for (final file in _dartFilesIn(root)) {
final relative = p.relative(file.path);
if (_platformModules.contains(relative)) {
continue;
}
final match = _platformImport.firstMatch(file.readAsStringSync());
if (match != null) {
offenders.add('$relative imports ${match.group(1)}');
}
}
}
expect(offenders, isEmpty, reason: offenders.join('\n'));
});
test('nothing the common barrel reaches imports a platform package', () {
final reached = _closureOfCommonBarrel();
final offenders = [
for (final package in _platformPackages)
if (reached.any((uri) => uri.startsWith('package:$package/'))) package,
];
expect(
offenders,
isEmpty,
reason:
'A file the common barrel transitively reaches pulls a desktop '
'platform package into all ${reached.where((f) => f.startsWith('lib/')).length} '
'of its dependencies:\n${offenders.join('\n')}',
);
});
test('the common barrel never reaches the view tree', () {
final reached = _closureOfCommonBarrel();
final views =
reached.where((file) => file.startsWith('lib/views/')).toList()..sort();
expect(
views,
isEmpty,
reason:
'A widget is reachable from `lib/common/common.dart`, so every file '
'that imports the barrel for a string helper now compiles the UI. '
'This is how `common/navigation.dart` and the widget on '
'`DashboardWidget` each put ~60 view files in the closure. Data the '
'provider layer needs is passed through a port in '
'`common/app_ports.dart`; the layer that owns the widgets binds '
'it:\n${views.join('\n')}',
);
});
test('common never depends on the manager barrel', () {
final offenders = <String>[];
for (final file in _dartFilesIn('lib/common')) {
if (file.readAsStringSync().contains(
"import 'package:fl_clash/manager/manager.dart';",
)) {
offenders.add(
'${p.relative(file.path)} — import the one manager it needs, not the '
'barrel that reaches every platform manager',
);
}
}
expect(offenders, isEmpty, reason: offenders.join('\n'));
});
}
+55
View File
@@ -0,0 +1,55 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
const _uiRoots = ['lib/views', 'lib/widgets', 'lib/pages', 'lib/features'];
const _forbidden = <String, String>{
r'globalState\.container':
'read providers through a WidgetRef, not the global container',
r'(?<![\w.])coreController\b':
'reach Core through coreHandlerProvider, not the global singleton',
};
bool _isGenerated(String path) {
return path.contains('/generated/') ||
path.endsWith('.g.dart') ||
path.endsWith('.freezed.dart');
}
void main() {
test('the UI layer never reaches a process-wide singleton directly', () {
final offenders = <String>[];
for (final root in _uiRoots) {
final directory = Directory(root);
if (!directory.existsSync()) {
fail('$root no longer exists; update _uiRoots.');
}
for (final entity in directory.listSync(recursive: true)) {
if (entity is! File || !entity.path.endsWith('.dart')) {
continue;
}
final relative = p.relative(entity.path);
if (_isGenerated(relative)) {
continue;
}
final lines = entity.readAsLinesSync();
for (var i = 0; i < lines.length; i++) {
final line = lines[i];
if (line.trimLeft().startsWith('//')) {
continue;
}
for (final entry in _forbidden.entries) {
if (RegExp(entry.key).hasMatch(line)) {
offenders.add('$relative:${i + 1} — ${entry.value}');
}
}
}
}
}
expect(offenders, isEmpty, reason: offenders.join('\n'));
});
}
+25
View File
@@ -46,5 +46,30 @@ void main() {
'split-per-abi', 'split-per-abi',
]); ]);
}); });
test('refuses to package while a native build hook is skipped', () {
const pubspec = '''
hooks:
user_defines:
setup:
build_assets: false
rust_api:
build_assets: true
''';
expect(setup.packagesNotBuildingAssets(pubspec), ['setup']);
expect(setup.packagesNotBuildingAssets('name: x\n'), isEmpty);
});
test('packages every Linux format on every architecture', () {
expect(setup.createPackageTargets('linux', null), 'deb,appimage,rpm');
expect(setup.createPackageTargets('linux', 'deb'), 'deb');
expect(setup.createPackageTargets('macos', null), 'dmg');
});
test('downloads the appimagetool build matching the host', () {
expect(setup.appImageToolArch('arm64'), 'aarch64');
expect(setup.appImageToolArch('amd64'), 'x86_64');
});
}); });
} }
+210
View File
@@ -0,0 +1,210 @@
import 'dart:io';
import 'package:test/test.dart';
import '../../tool/src/changelog/builder.dart';
import '../../tool/src/changelog/git.dart';
import '../../tool/src/changelog/models.dart';
import '../../tool/src/changelog/render.dart';
void main() {
late Directory repo;
void git(List<String> arguments) {
final result = Process.runSync(
'git',
arguments,
workingDirectory: repo.path,
environment: const {
'GIT_AUTHOR_DATE': '2026-01-02T00:00:00Z',
'GIT_COMMITTER_DATE': '2026-01-02T00:00:00Z',
},
);
if (result.exitCode != 0) {
fail('git ${arguments.join(' ')} failed: ${result.stderr}');
}
}
void commit(String message) {
git(['commit', '--allow-empty', '--quiet', '--message', message]);
}
setUp(() {
repo = Directory.systemTemp.createTempSync('flclash-changelog-');
git(['init', '--quiet', '--initial-branch=main']);
git(['config', 'user.email', 'changelog-test@example.com']);
git(['config', 'user.name', 'Changelog test']);
commit('feat: initial release');
git(['tag', 'v1.0.0']);
commit('feat(a): first feature');
commit('fix(b): first fix');
git(['tag', 'backup-pre-squash-deadbee']);
git(['tag', 'v1.1.0-pre.1']);
commit('feat(c)!: second feature\n\nBREAKING CHANGE: The old flag is gone');
git(['tag', 'v1.1.0']);
commit('chore: bump build number');
});
tearDown(() {
repo.deleteSync(recursive: true);
});
ChangelogBuildResult build({PendingVersion? pending}) => ChangelogBuilder(
Git(workingDirectory: repo.path),
boundary: 'v1.0.0',
).build(pending: pending);
test('collects one section per stable tag above the boundary', () {
final versions = build().changelog.versions;
expect(versions.map((version) => version.tag), ['v1.1.0']);
expect(versions.single.date, '2026-01-02');
});
test('preserves frozen history before the first structured release', () {
git(['checkout', '--quiet', '--orphan', 'frozen-history']);
commit('chore: optimize commented policy');
git(['tag', 'v0.8.96']);
final builder = ChangelogBuilder(Git(workingDirectory: repo.path));
expect(builder.build().changelog.versions, isEmpty);
commit('fix: new release fix');
final next = builder
.build(
pending: const PendingVersion(version: '0.8.97', date: '2026-09-10'),
)
.changelog;
expect(next.versions.single.tag, 'v0.8.97');
expect(
next.versions.single.groups.single.entries.single.text,
'New release fix',
);
const history =
'## v0.8.96 (2026-08-17)\n\n'
'- Optimize commented policy\n'
'- Fix whole group delay test failing on Windows\n'
'- Optimize package icon loading and connections polling\n';
final original = '${renderMarkdown(builder.build().changelog)}$history';
final merged = mergeMarkdown(renderMarkdown(next), original);
expect(merged.substring(merged.indexOf('## v0.8.96')), history);
});
test('a prerelease tag does not split the stable range', () {
final groups = build().changelog.versions.single.groups;
final features = groups
.firstWhere((group) => group.type == ChangelogType.feat)
.entries
.map((entry) => entry.text);
expect(features, ['Second feature', 'First feature']);
});
test('ignores tags that are not release tags', () {
final tags = Git(workingDirectory: repo.path).versionTags();
expect(tags.map((tag) => tag.name), ['v1.1.0', 'v1.1.0-pre.1', 'v1.0.0']);
});
test('lifts breaking changes into their own group', () {
final groups = build().changelog.versions.single.groups;
expect(groups.first.type, ChangelogType.breaking);
expect(groups.first.entries.single.text, 'The old flag is gone');
});
test('a pending version covers the commits after the newest stable tag', () {
final versions = build(
pending: const PendingVersion(version: '1.2.0', date: '2026-02-03'),
).changelog.versions;
expect(versions.map((version) => version.tag), ['v1.2.0', 'v1.1.0']);
expect(versions.first.date, '2026-02-03');
expect(versions.first.isEmpty, isTrue);
});
test('a pending version is skipped once its tag exists', () {
final versions = build(
pending: const PendingVersion(version: '1.1.0', date: '2026-02-03'),
).changelog.versions;
expect(versions.map((version) => version.tag), ['v1.1.0']);
expect(versions.single.date, '2026-01-02');
});
test('a release tag on another branch is not reachable from this one', () {
git(['checkout', '--quiet', '-b', 'feature', 'v1.0.0']);
commit('feat(d): work started before the release');
final scoped = Git(workingDirectory: repo.path);
expect(
scoped.tagExists('v1.1.0'),
isTrue,
reason: 'the tag is still in the repository',
);
expect(
scoped.tagIsReachable('v1.1.0'),
isFalse,
reason:
'verify must skip versions the builder cannot derive here, or every '
'branch forked before the newest release fails on an unrelated tag.',
);
expect(
scoped.versionTags().map((tag) => tag.name),
isNot(contains('v1.1.0')),
reason: 'tagIsReachable must agree with the tags the builder walks.',
);
expect(
scoped.tagIsReachable('v1.0.0'),
isTrue,
reason: 'a tag this branch was cut from is still checked.',
);
});
test('a release tag on this branch stays checked', () {
final scoped = Git(workingDirectory: repo.path);
expect(scoped.tagIsReachable('v1.1.0'), isTrue);
expect(
scoped.versionTags().map((tag) => tag.name),
contains('v1.1.0'),
reason:
'the skip must be narrow; on the release branch verify still '
'compares every derivable version.',
);
});
test('a tag with no version in changelog.json is reported', () {
final derived = build().changelog;
final recorded = Changelog(
versions: derived.versions
.where((version) => version.tag != 'v1.1.0')
.toList(),
);
expect(missingReleaseTags(recorded, derived), ['v1.1.0']);
expect(missingReleaseTags(derived, derived), isEmpty);
});
test('is deterministic across runs', () {
expect(
encodeChangelog(build().changelog),
encodeChangelog(build().changelog),
);
});
test('reports commits that are not conventional', () {
commit('Optimize core service');
final warnings = build(
pending: const PendingVersion(version: '1.2.0', date: '2026-02-03'),
).warnings;
expect(warnings.single, contains('not a conventional commit'));
});
}
+247
View File
@@ -0,0 +1,247 @@
import 'package:test/test.dart';
import '../../tool/src/changelog/models.dart';
import '../../tool/src/changelog/parser.dart';
RawCommit commit(String subject, [String body = '']) =>
RawCommit(hash: 'abcdef1234567890', subject: subject, body: body);
void main() {
group('ChangelogParser', () {
test('uses the Changelog trailer as the user facing text', () {
final parser = ChangelogParser();
final items = parser.parse(
commit('feat(profiles): support per-profile override script', '''
Adds a Dart-side hook.
Changelog: Per-profile override scripts
'''),
);
expect(items, hasLength(1));
expect(items.single.type, ChangelogType.feat);
expect(items.single.entry.scope, 'profiles');
expect(items.single.entry.id, 'abcdef1');
expect(items.single.entry.text, 'Per-profile override scripts');
expect(parser.warnings, isEmpty);
});
test('falls back to the capitalized subject description', () {
final items = ChangelogParser().parse(
commit('fix(windows): keep the TUN adapter alive'),
);
expect(items.single.type, ChangelogType.fix);
expect(items.single.entry.text, 'Keep the TUN adapter alive');
});
test('drops commits marked Changelog: skip', () {
final items = ChangelogParser().parse(
commit('feat(core): internal only', 'Changelog: skip'),
);
expect(items, isEmpty);
});
test('drops non user facing types without a trailer', () {
final parser = ChangelogParser();
expect(parser.parse(commit('refactor(lib): restructure state')), isEmpty);
expect(parser.parse(commit('ci: enforce lint')), isEmpty);
expect(parser.parse(commit('test: cover lifecycle')), isEmpty);
expect(parser.warnings, isEmpty);
});
test('keeps a non user facing type when it carries a trailer', () {
final items = ChangelogParser().parse(
commit('refactor(tray): replace the fork', 'Changelog: Rebuilt tray'),
);
expect(items.single.type, ChangelogType.feat);
expect(items.single.entry.text, 'Rebuilt tray');
});
test('honours Changelog-Type over the commit type', () {
final items = ChangelogParser().parse(
commit('chore(core): bump core', '''
Changelog: Update core
Changelog-Type: perf
'''),
);
expect(items.single.type, ChangelogType.perf);
});
test('warns and drops an unknown Changelog-Type', () {
final parser = ChangelogParser();
final items = parser.parse(
commit('chore(core): bump core', '''
Changelog: Update core
Changelog-Type: unknown-type
'''),
);
expect(items, isEmpty);
expect(parser.warnings.single, contains('unknown Changelog-Type'));
});
test('emits a breaking entry next to the regular one', () {
final parser = ChangelogParser();
final items = parser.parse(
commit('feat(backup)!: new archive layout', '''
Changelog: Faster backups
BREAKING CHANGE: Archives from 0.8.95 and earlier need re-import
'''),
);
expect(items.map((item) => item.type), [
ChangelogType.breaking,
ChangelogType.feat,
]);
expect(
items.first.entry.text,
'Archives from 0.8.95 and earlier need re-import',
);
expect(items.last.entry.text, 'Faster backups');
expect(parser.warnings, isEmpty);
});
test('warns when a breaking commit has no footer', () {
final parser = ChangelogParser();
final items = parser.parse(commit('fix(core)!: drop the legacy socket'));
expect(items.first.type, ChangelogType.breaking);
expect(items.first.entry.text, 'Drop the legacy socket');
expect(parser.warnings.single, contains('BREAKING CHANGE'));
});
test('treats a BREAKING CHANGE footer alone as breaking', () {
final items = ChangelogParser().parse(
commit(
'fix(core): tighten the handshake',
'BREAKING CHANGE: Old cores',
),
);
expect(items.map((item) => item.type), [
ChangelogType.breaking,
ChangelogType.fix,
]);
});
test('joins continuation lines of a trailer', () {
final items = ChangelogParser().parse(
commit('feat: multi line', '''
Changelog: A long entry
that wraps onto a second line
Some other paragraph.
'''),
);
expect(
items.single.entry.text,
'A long entry that wraps onto a second line',
);
});
test('stops a trailer at the next unrelated git trailer', () {
final items = ChangelogParser().parse(
commit('feat: multi line', '''
Changelog: A long entry
Co-authored-by: Someone <someone@example.com>
'''),
);
expect(items.single.entry.text, 'A long entry');
});
test('stops a trailer at a git trailer with no space after the colon', () {
final items = ChangelogParser().parse(
commit('feat: multi line', '''
Changelog: A long entry
Claude-Session:https://claude.ai/code/session_abc
'''),
);
expect(items.single.entry.text, 'A long entry');
});
test('joins a Note: line as a continuation, not a trailer', () {
final items = ChangelogParser().parse(
commit('feat: multi line', '''
Changelog: A long entry
Note: the old handle still works
'''),
);
expect(
items.single.entry.text,
'A long entry Note: the old handle still works',
);
});
test('warns about a translation trailer left in a commit', () {
final parser = ChangelogParser();
final items = parser.parse(commit('feat: x', 'Changelog-zh-CN: 新功能'));
expect(items.single.entry.text, 'X');
expect(
parser.warnings.single,
contains('unsupported changelog trailer: Changelog-zh-CN'),
);
});
test('warns about a breaking translation trailer', () {
final parser = ChangelogParser();
parser.parse(commit('feat: x', 'Breaking-zh-CN: 变更'));
expect(
parser.warnings.single,
contains('unsupported changelog trailer: Breaking-zh-CN'),
);
});
test('falls back to the subject when Changelog: carries no text', () {
final items = ChangelogParser().parse(
commit('fix(core): keep the socket alive', 'Changelog:'),
);
expect(items.single.entry.text, 'Keep the socket alive');
});
test('warns about a commit that is not conventional', () {
final parser = ChangelogParser();
expect(parser.parse(commit('Optimize core service')), isEmpty);
expect(parser.warnings.single, contains('not a conventional commit'));
});
test('handles a missing scope', () {
final items = ChangelogParser().parse(commit('feat: add a thing'));
expect(items.single.entry.scope, isNull);
});
});
group('groupItems', () {
test('orders groups by declaration and drops empty ones', () {
final items = ChangelogParser().parseAll([
commit('fix: b'),
commit('feat: a'),
commit('perf: c'),
commit('feat: d'),
]);
final groups = groupItems(items);
expect(groups.map((group) => group.type), [
ChangelogType.feat,
ChangelogType.fix,
ChangelogType.perf,
]);
expect(groups.first.entries.map((entry) => entry.text), ['A', 'D']);
});
});
}
+306
View File
@@ -0,0 +1,306 @@
import 'package:test/test.dart';
import '../../tool/src/changelog/builder.dart';
import '../../tool/src/changelog/models.dart';
import '../../tool/src/changelog/render.dart';
ChangelogVersion version({
String tag = 'v0.8.96',
String date = '2026-08-16',
bool prerelease = false,
List<ChangelogGroup> groups = const [],
}) => ChangelogVersion(
version: tag.substring(1),
tag: tag,
date: date,
prerelease: prerelease,
groups: groups,
);
ChangelogGroup entryGroup(
ChangelogType type,
List<(String, String?, String)> entries,
) => ChangelogGroup(
type: type,
entries: [
for (final (id, scope, text) in entries)
ChangelogEntry(id: id, scope: scope, text: text),
],
);
void main() {
group('renderMarkdown', () {
test('renders versions, groups and the frozen marker', () {
final markdown = renderMarkdown(
Changelog(
versions: [
version(
groups: [
entryGroup(ChangelogType.breaking, [
('af20769', 'lib', 'Backups need re-import'),
]),
entryGroup(ChangelogType.feat, [
('1a2b3c4', 'profiles', 'Per-profile override scripts'),
('2b3c4d5', null, 'Scopeless entry'),
]),
],
),
],
),
);
expect(markdown, startsWith('# Changelog\n'));
expect(markdown, contains('## v0.8.96 (2026-08-16)'));
expect(markdown, contains('**Breaking Changes**'));
expect(markdown, contains('- **lib** Backups need re-import (af20769)'));
expect(markdown, contains('- Scopeless entry (2b3c4d5)'));
expect(markdown, contains(changelogFrozenMarker));
expect(markdown.indexOf('**Features**'), greaterThan(0));
expect(markdown, isNot(contains('### ')));
});
});
group('mergeMarkdown', () {
const legacy = '## v0.8.95\n\n- Optimize core service\n';
test('keeps a legacy file below the frozen marker', () {
final merged = mergeMarkdown(
renderMarkdown(const Changelog(versions: [])),
legacy,
);
expect(merged, contains(changelogFrozenMarker));
expect(merged, endsWith(legacy));
expect(
merged.indexOf(legacy),
greaterThan(merged.indexOf(changelogFrozenMarker)),
);
});
test('is idempotent once the marker exists', () {
final rendered = renderMarkdown(const Changelog(versions: []));
final once = mergeMarkdown(rendered, legacy);
final twice = mergeMarkdown(rendered, once);
expect(twice, once);
});
test('replaces only the generated head on a rerun', () {
final first = mergeMarkdown(
renderMarkdown(const Changelog(versions: [])),
legacy,
);
final second = mergeMarkdown(
renderMarkdown(
Changelog(
versions: [
version(
groups: [
entryGroup(ChangelogType.fix, [
('5d6e7f8', 'windows', 'Fix TUN'),
]),
],
),
],
),
),
first,
);
expect(second, contains('- **windows** Fix TUN (5d6e7f8)'));
expect(second, endsWith(legacy));
});
});
group('markdownHead', () {
test('survives a reworded frozen note', () {
const rendered =
'# Changelog\n\n$changelogFrozenMarker\n<!-- an older wording -->\n\n';
final merged = mergeMarkdown(rendered, '## v0.8.95\n\n- Old\n');
expect(merged, isNot(contains('an older wording\n<!--')));
expect(merged.split('<!--').length - 1, 2);
expect(merged, endsWith('- Old\n'));
});
test('cuts at the frozen note', () {
final rendered = renderMarkdown(const Changelog(versions: []));
final merged = mergeMarkdown(rendered, '## v0.8.95\n\n- Old\n');
expect(markdownHead(merged), rendered.trimRight());
expect(markdownHead(merged), isNot(contains('Old')));
});
});
group('renderRelease', () {
test('wraps entries in the app readable markers', () {
final body = renderRelease(
version(
groups: [
entryGroup(ChangelogType.feat, [
('1a2b3c4', 'profiles', 'Scripts'),
]),
],
),
);
expect(body, startsWith(releaseBeginMarker));
expect(body, contains(releaseEndMarker));
expect(body, contains('- **profiles** Scripts'));
});
test('escapes the characters the caption parse mode would read', () {
final text = renderTelegram(
version(
groups: [
entryGroup(ChangelogType.fix, [
('5d6e7f8', 'core', 'Reject <script> & "quoted" input'),
]),
],
),
moreUrl: 'https://example.com',
);
expect(
text,
'<b>Bug Fixes</b>\n• Reject &lt;script&gt; &amp; "quoted" input',
);
});
test('says so when a version has no user facing changes', () {
expect(renderRelease(version()), contains(emptyVersionNote));
});
test('carries the translated payload the app reads', () {
final body = renderRelease(
version(
groups: [
const ChangelogGroup(
type: ChangelogType.feat,
entries: [ChangelogEntry(id: '1a2b3c4', text: 'Scripts')],
),
],
),
);
final payload = _payloadOf(body);
final decoded = decodeChangelog(payload);
expect(decoded.versions.single.tag, 'v0.8.96');
expect(
decoded.versions.single.groups.single.entries.single.text,
'Scripts',
);
expect(body.trimRight(), endsWith(releaseJsonEndMarker));
});
test('escapes > so an entry cannot close the comment early', () {
final body = renderRelease(
version(
groups: [
entryGroup(ChangelogType.fix, [
('1a2b3c4', null, 'Handle a --> sequence'),
]),
],
),
);
final payload = _payloadOf(body);
expect(payload, isNot(contains(releaseJsonEndMarker)));
expect(
decodeChangelog(
payload,
).versions.single.groups.single.entries.single.text,
'Handle a --> sequence',
);
});
});
group('renderTelegram', () {
test('renders bullets without markdown scopes', () {
final text = renderTelegram(
version(
groups: [
entryGroup(ChangelogType.fix, [('5d6e7f8', 'windows', 'Fix TUN')]),
],
),
moreUrl: 'https://example.com',
);
expect(text, '<b>Bug Fixes</b>\n• Fix TUN');
});
test('says so when a version has no user facing changes', () {
expect(
renderTelegram(version(), moreUrl: 'https://example.com'),
'• $emptyVersionNote',
);
});
test('truncates past the caption limit and links out', () {
final text = renderTelegram(
version(
groups: [
entryGroup(ChangelogType.feat, [
for (var index = 0; index < 200; index++)
('hash$index', null, 'A reasonably long changelog entry'),
]),
],
),
moreUrl: 'https://example.com',
);
expect(text.length, lessThan(telegramLimit + 40));
expect(text, endsWith('https://example.com'));
});
});
group('encodeChangelog', () {
test('round trips through json', () {
final changelog = Changelog(
versions: [
version(
groups: [
entryGroup(ChangelogType.feat, [
('1a2b3c4', 'profiles', 'Scripts'),
]),
],
),
],
);
final decoded = decodeChangelog(encodeChangelog(changelog));
expect(decoded.versions.single.tag, 'v0.8.96');
expect(decoded.versions.single.groups.single.type, ChangelogType.feat);
expect(
decoded.versions.single.groups.single.entries.single.scope,
'profiles',
);
});
test('rejects a future schema version', () {
final decoded = decodeChangelog('{"schemaVersion": 99, "versions": []}');
expect(decoded.versions, isEmpty);
});
test('ends with a newline so the file is diff friendly', () {
expect(encodeChangelog(const Changelog(versions: [])), endsWith('\n'));
});
});
group('readPubspecVersion', () {
test('reads the app version without the build number', () {
expect(
readPubspecVersion('name: fl_clash\nversion: 0.8.96+2026081501\n'),
'0.8.96',
);
});
});
}
String _payloadOf(String body) {
final start =
body.indexOf(releaseJsonBeginMarker) + releaseJsonBeginMarker.length;
return body.substring(start, body.indexOf(releaseJsonEndMarker, start));
}
+53
View File
@@ -0,0 +1,53 @@
import 'dart:typed_data';
import 'package:test/test.dart';
import '../../tool/src/icons/ico.dart';
Uint8List _png(int fill, int length) =>
Uint8List.fromList(List.filled(length, fill));
int _u16(Uint8List bytes, int offset) =>
bytes[offset] | (bytes[offset + 1] << 8);
int _u32(Uint8List bytes, int offset) =>
_u16(bytes, offset) | (_u16(bytes, offset + 2) << 16);
void main() {
test('writes one directory entry per size, sorted, with correct offsets', () {
final ico = buildIco([
IcoEntry(size: 256, png: _png(3, 7)),
IcoEntry(size: 16, png: _png(1, 5)),
IcoEntry(size: 32, png: _png(2, 6)),
]);
expect(_u16(ico, 0), 0);
expect(_u16(ico, 2), 1);
expect(_u16(ico, 4), 3);
const firstImage = 6 + 16 * 3;
expect([ico[6], ico[7]], [16, 16]);
expect(_u32(ico, 6 + 8), 5);
expect(_u32(ico, 6 + 12), firstImage);
expect([ico[22], ico[23]], [32, 32]);
expect(_u32(ico, 22 + 12), firstImage + 5);
expect([ico[38], ico[39]], [0, 0]);
expect(_u32(ico, 38 + 12), firstImage + 11);
expect(ico.sublist(firstImage, firstImage + 5), everyElement(1));
expect(ico.sublist(firstImage + 11), everyElement(3));
});
test('rejects sizes outside the ICO range and empty input', () {
expect(() => buildIco([]), throwsArgumentError);
expect(
() => buildIco([IcoEntry(size: 512, png: _png(0, 1))]),
throwsArgumentError,
);
});
test('tray sizes stop at the 2x small-icon ceiling', () {
expect(trayIcoSizes.last, 64);
expect(icoSizes, containsAll(trayIcoSizes));
expect(icoSizes.last, 256);
});
}
+44
View File
@@ -0,0 +1,44 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:yaml/yaml.dart';
void main() {
group('Linux packaging teardown', () {
for (final format in ['deb', 'rpm']) {
test('$format removes the Helper unit only on a real uninstall', () {
final config =
loadYaml(
File(
'linux/packaging/$format/make_config.yaml',
).readAsStringSync(),
)
as YamlMap;
final scripts = (config['postuninstall_scripts'] as YamlList)
.cast<String>();
expect(scripts.first, contains('exit 0'));
expect(
scripts,
contains('rm -f /etc/systemd/system/flclash-helper.service'),
);
expect(
scripts.any((script) => script.contains('systemctl daemon-reload')),
isTrue,
);
});
}
});
test('rpm keeps the Core bytes the Helper was built against', () {
final config =
loadYaml(
File('linux/packaging/rpm/make_config.yaml').readAsStringSync(),
)
as YamlMap;
final macros = (config['spec_macros'] as YamlList).cast<String>();
expect(macros, contains('%global debug_package %{nil}'));
expect(macros, contains('%global __os_install_post %{nil}'));
});
}
+279
View File
@@ -0,0 +1,279 @@
import 'dart:io';
import 'package:args/args.dart';
import 'src/changelog/builder.dart';
import 'src/changelog/git.dart';
import 'src/changelog/models.dart';
import 'src/changelog/render.dart';
const _repository = 'chen08209/FlClash';
Future<void> main(List<String> arguments) async {
final parser = ArgParser()
..addOption('root', help: 'Repository root, defaults to the cwd.');
parser.addCommand('release')
..addOption('version', help: 'Version being released, without the v.')
..addOption('date', help: 'Release date, defaults to today.');
parser
.addCommand('build')
.addFlag(
'unreleased',
negatable: false,
help: 'Include commits made after the newest stable tag.',
);
parser.addCommand('render')
..addOption('tag', help: 'Version tag to render, defaults to the newest.')
..addOption('out', help: 'Output file, defaults to stdout.');
parser.addCommand('verify');
final ArgResults results;
try {
results = parser.parse(arguments);
} on FormatException catch (error) {
_fail('${error.message}\n\n${_usage(parser)}');
}
final command = results.command;
if (command == null) {
_fail(_usage(parser));
}
final root = results.option('root') ?? Directory.current.path;
final exitCodeValue = switch (command.name) {
'release' => _release(root, command),
'build' => _build(root, command),
'render' => _render(root, command),
'verify' => _verify(root),
_ => _fail(_usage(parser)),
};
exit(exitCodeValue);
}
int _release(String root, ArgResults command) {
final version = command.option('version');
if (version == null) {
_fail('release needs --version, for example --version 0.8.96');
}
final result = ChangelogBuilder(Git(workingDirectory: root)).build(
pending: PendingVersion(
version: version,
date: command.option('date') ?? today(),
),
);
_printWarnings(result.warnings);
_writeData(root, result.changelog);
_writeMarkdown(root, result.changelog);
stdout.writeln('Wrote $changelogMarkdownPath and $changelogDataPath');
return 0;
}
int _build(String root, ArgResults command) {
final unreleased = command.flag('unreleased');
final pubspec = resolve(root, 'pubspec.yaml').readAsStringSync();
final git = Git(workingDirectory: root);
PendingVersion? pending;
if (unreleased) {
pending = PendingVersion(
version: readPubspecVersion(pubspec),
date: today(),
prerelease: true,
);
// The builder drops a pending version whose tag already exists, which for a
// prerelease build is silent and wrong: render then picks the newest
// version in the file, and the prerelease ships the previous release's
// notes under its own name.
if (git.tagExists(pending.tag)) {
_fail(
'build --unreleased has nothing to collect: ${pending.tag} is already '
'tagged. Bump the version in pubspec.yaml first, or this build would '
"publish the previous release's notes.",
);
}
}
final result = ChangelogBuilder(git).build(pending: pending);
_printWarnings(result.warnings);
_writeData(root, result.changelog);
stdout.writeln('Wrote $changelogDataPath');
return 0;
}
int _render(String root, ArgResults command) {
final rest = command.rest;
if (rest.isEmpty) {
_fail('render needs a target: release or telegram');
}
final changelog = decodeChangelog(
resolve(root, changelogDataPath).readAsStringSync(),
);
if (changelog.versions.isEmpty) {
_fail('$changelogDataPath has no versions to render');
}
final tag = command.option('tag');
final version = tag == null
? changelog.versions.first
: changelog.versions.firstWhere(
(item) => item.tag == tag,
orElse: () => _fail('$changelogDataPath has no version $tag'),
);
final output = switch (rest.first) {
'release' => renderRelease(version),
'telegram' => renderTelegram(
version,
moreUrl: 'https://github.com/$_repository/releases',
),
_ => _fail('Unknown render target: ${rest.first}'),
};
final out = command.option('out');
if (out == null) {
stdout.write(output);
} else {
resolve(root, out).writeAsStringSync(output);
}
return 0;
}
int _verify(String root) {
final dataFile = resolve(root, changelogDataPath);
final markdownFile = resolve(root, changelogMarkdownPath);
final problems = <String>[];
if (!dataFile.existsSync()) {
_fail('$changelogDataPath is missing; run tool/changelog.dart release');
}
final changelog = decodeChangelog(dataFile.readAsStringSync());
final markdown = markdownFile.readAsStringSync();
if (!markdown.contains(changelogFrozenMarker)) {
problems.add('$changelogMarkdownPath is missing the frozen marker');
} else if (markdownHead(markdown).trimRight() !=
renderMarkdown(changelog).trimRight()) {
problems.add(
'$changelogMarkdownPath does not match $changelogDataPath; '
'run tool/changelog.dart release to regenerate it',
);
}
final git = Git(workingDirectory: root);
final expected = ChangelogBuilder(git).build();
_printWarnings(expected.warnings);
final expectedByTag = <String, ChangelogVersion>{
for (final version in expected.changelog.versions) version.tag: version,
};
final unreachable = <String>[];
for (final version in changelog.versions) {
if (!git.tagIsReachable(version.tag)) {
if (git.tagExists(version.tag)) {
unreachable.add(version.tag);
}
continue;
}
final reference = expectedByTag[version.tag];
if (reference == null) {
problems.add(
'${version.tag} is in changelog.json but not derivable from git',
);
continue;
}
final missing = _fingerprint(reference).difference(_fingerprint(version));
if (missing.isNotEmpty) {
problems.add(
'${version.tag} is missing entries git can derive: '
'${missing.join(', ')}.\n'
' Regenerate, or mark those commits with "Changelog: skip".',
);
}
final known = expected.commitIdsByTag[version.tag] ?? const <String>{};
final unknown = <String>[
for (final group in version.groups)
for (final entry in group.entries)
if (!known.contains(entry.id)) entry.id,
];
if (unknown.isNotEmpty) {
problems.add(
'${version.tag} has entries pointing at commits outside its range: '
'${unknown.join(', ')}',
);
}
}
// A tag git can derive a version for but changelog.json never recorded means
// someone tagged a release without running `release` first. Without this the
// omission only shows up at the very end of the release, when `render` finds
// no notes for the tag being published.
for (final tag in missingReleaseTags(changelog, expected.changelog)) {
problems.add(
'$tag is tagged in git but missing from $changelogDataPath; '
'run tool/changelog.dart release --version '
'${tag.startsWith('v') ? tag.substring(1) : tag}',
);
}
if (unreachable.isNotEmpty) {
stdout.writeln(
'Skipped ${unreachable.join(', ')}: tagged outside this branch, so git '
'derives nothing to compare here.',
);
}
if (problems.isEmpty) {
stdout.writeln('Changelog is consistent with git.');
return 0;
}
for (final problem in problems) {
stderr.writeln('✗ $problem');
}
return 1;
}
/// Identity of an entry for drift detection: which group it landed in and which
/// commit it came from. Wording and scope are deliberately excluded so a
/// maintainer can polish the text of a released version without failing CI.
Set<String> _fingerprint(ChangelogVersion version) => <String>{
for (final group in version.groups)
for (final entry in group.entries) '${group.type.id}:${entry.id}',
};
void _writeData(String root, Changelog changelog) {
final file = resolve(root, changelogDataPath);
file.parent.createSync(recursive: true);
file.writeAsStringSync(encodeChangelog(changelog));
}
void _writeMarkdown(String root, Changelog changelog) {
final file = resolve(root, changelogMarkdownPath);
final existing = file.existsSync() ? file.readAsStringSync() : '';
file.writeAsStringSync(mergeMarkdown(renderMarkdown(changelog), existing));
}
void _printWarnings(List<String> warnings) {
for (final warning in warnings) {
stderr.writeln('! $warning');
}
}
String _usage(ArgParser parser) => '''
Usage: dart run tool/changelog.dart <command> [options]
Commands:
release --version X Rewrite CHANGELOG.md and changelog.json.
build [--unreleased] Rewrite changelog.json only.
render <release|telegram> [--tag vX] [--out file]
verify Check the committed changelog against git.
${parser.usage}''';
Never _fail(String message) {
stderr.writeln(message);
exit(64);
}
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
set -euo pipefail
# Git exports these to its hooks, and they outrank the `-C <dir>` the probes
# below rely on: every file then resolves its own directory as the repository,
# so a main checkout skips the file and a linked worktree measures all of it.
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
max_density="${COMMENT_DENSITY_MAX:-5}"
min_lines="${COMMENT_DENSITY_MIN_LINES:-20}"
is_checkable() {
local file="$1"
case "$file" in
*.dart | *.kt | *.kts | *.swift | *.go | *.rs | *.java | *.cpp | *.cc | *.h | *.hpp | *.gradle | *.yaml | *.yml) ;;
*) return 1 ;;
esac
case "$file" in
*/build/* | */generated/* | */l10n/intl/* | */l10n/l10n.dart | */Clash.Meta/* | */.dart_tool/*) return 1 ;;
*.g.dart | *.freezed.dart | */frb_generated*.dart | */frb_generated.rs | */open_container.dart) return 1 ;;
esac
return 0
}
added_for() {
local file="$1"
[ -f "$file" ] || return 0
is_checkable "$file" || return 0
local repo relative
repo="$(git -C "$(dirname "$file")" rev-parse --show-toplevel 2>/dev/null || true)"
[ -n "$repo" ] || return 0
relative="$file"
case "$file" in
"$repo"/*) relative="${file#"$repo"/}" ;;
esac
if git -C "$repo" ls-files --error-unmatch "$relative" >/dev/null 2>&1; then
git -C "$repo" diff HEAD -U0 -- "$relative" | grep -E '^\+' | grep -vE '^\+\+\+' |
sed "s|^+|$relative\t|" || true
else
sed "s|^|$relative\t|" "$file" || true
fi
}
comment_lines() {
local body="$1"
printf '%s\n' "$body" |
grep -E "$(printf '\t')[[:space:]]*(//|/\*|#|\*([[:space:]]|/|$))" |
grep -vE 'ignore:|ignore_for_file:|coverage:ignore|Copyright|SPDX-|Licensed under|GENERATED|dart format off|yaml-language-server|[[:space:]]#!$|[[:space:]]#!/|#!?\[|#(include|define|undef|ifdef|ifndef|if|elif|else|endif|pragma|error)\b' || true
}
changed_files() {
local repo="$1"
{
git -C "$repo" diff HEAD --name-only -z 2>/dev/null || true
git -C "$repo" ls-files --others --exclude-standard -z 2>/dev/null || true
}
}
evaluate() {
local file="$1" added total hits count density
added="$(added_for "$file")"
[ -n "$added" ] || return 0
total="$(printf '%s\n' "$added" | grep -c . || true)"
[ "$total" -ge "$min_lines" ] || return 0
hits="$(comment_lines "$added")"
count="$(printf '%s' "$hits" | grep -c . || true)"
[ "$count" -gt 0 ] || return 0
density="$(awk -v c="$count" -v t="$total" 'BEGIN { printf "%.1f", c * 100 / t }')"
[ "$(awk -v d="$density" -v m="$max_density" 'BEGIN { print (d > m) ? 1 : 0 }')" -eq 1 ] || return 0
printf '%s: %s of %s added lines are standalone comments (%s%%)\n\n' \
"$(printf '%s\n' "$added" | head -1 | cut -f1)" "$count" "$total" "$density"
printf '%s\n\n' "$(printf '%s\n' "$hits" | sed 's/\t/: /')"
}
collect() {
local file
for file in "$@"; do
case "$file" in
/*) ;;
*) file="$PWD/$file" ;;
esac
evaluate "$file"
done
}
collect_changed() {
local repo file
repo="$(git rev-parse --show-toplevel 2>/dev/null || true)"
[ -n "$repo" ] || return 0
while IFS= read -r -d '' file; do
evaluate "$repo/$file"
done < <(changed_files "$repo")
}
if [ "$#" -gt 0 ]; then
mode='argv'
findings="$(collect "$@")"
else
mode='hook'
payload="$(cat)"
file="$(printf '%s' "$payload" | jq -r '.tool_input.file_path // .tool_response.filePath // empty' 2>/dev/null || true)"
if [ -n "$file" ]; then
findings="$(collect "$file")"
else
findings="$(collect_changed)"
fi
fi
[ -n "$findings" ] || exit 0
cat >&2 <<REPORT
These files are over the ${max_density}% comment cap this repository holds to. Healthy changes
here sit under 4%; the outlier that prompted this gate ran 22%.
$findings
Comments are welcome when they carry something the code cannot say. Trim the ones that
restate what the code already does, narrate the change you just made, or annotate step by
step - a block needing a comment per line needs better names or a smaller decomposition
instead. Keep the few that record a non-obvious constraint, and prefer a test or an
.agents/ entry when the fact belongs there. See the Comments section of .agents/rules.md.
Set COMMENT_DENSITY_MAX to adjust this gate when a change genuinely warrants it.
REPORT
if [ "$mode" = 'argv' ]; then
exit 1
fi
exit 2
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
checker="$script_dir/check_comment_density.sh"
temp_dir="$(mktemp -d)"
trap 'rm -rf "$temp_dir"' EXIT
repo="$temp_dir/repo"
mkdir -p "$repo"
git -C "$repo" init --quiet
git -C "$repo" config user.email 'test@example.com'
git -C "$repo" config user.name 'Test'
printf 'void main() {}\n' >"$repo/tracked.dart"
git -C "$repo" add tracked.dart
git -C "$repo" commit --quiet -m 'seed'
failures=0
status=0
dense() {
local comments="$1" code="$2" i
echo 'void main() {'
for i in $(seq 1 "$comments"); do echo " // step $i explained"; done
for i in $(seq 1 "$code"); do echo " step$i();"; done
echo '}'
}
run_stdin() {
local target="${1:-}"
local payload='{"tool_name":"apply_patch","tool_input":{}}'
if [ -n "$target" ]; then
payload="$(printf '{"tool_name":"Edit","tool_input":{"file_path":"%s"}}' "$target")"
fi
set +e
printf '%s' "$payload" | (cd "$repo" && bash "$checker") >"$temp_dir/out" 2>"$temp_dir/err"
status=$?
set -e
}
run_argv() {
set +e
bash "$checker" "$@" >"$temp_dir/out" 2>"$temp_dir/err"
status=$?
set -e
}
expect_over() {
local name="$1" path="$2"
shift 2
mkdir -p "$(dirname "$path")"
printf '%s\n' "$@" >"$path"
run_stdin "$path"
if [ "$status" -ne 2 ]; then
echo "FAIL: $name should exit 2, got $status" >&2
failures=$((failures + 1))
elif ! grep -q 'added lines are standalone comments' "$temp_dir/err"; then
echo "FAIL: $name should report the density" >&2
failures=$((failures + 1))
fi
}
expect_under() {
local name="$1" path="$2"
shift 2
mkdir -p "$(dirname "$path")"
printf '%s\n' "$@" >"$path"
run_stdin "$path"
if [ "$status" -ne 0 ] || [ -s "$temp_dir/err" ]; then
echo "FAIL: $name should pass, got $status" >&2
cat "$temp_dir/err" >&2
failures=$((failures + 1))
fi
}
expect_over 'a diff that is a quarter comments' "$repo/dense.dart" "$(dense 8 24)"
expect_over 'block comment bodies count as comments' "$repo/block.kt" \
'/**' \
' * first constraint' \
' * second constraint' \
' * third constraint' \
' * fourth constraint' \
' * fifth constraint' \
' * sixth constraint' \
' * seventh constraint' \
' */' \
"$(dense 0 24)"
expect_over 'first-party plugin source is checked' \
"$repo/plugins/proxy/lib/dense.dart" "$(dense 8 24)"
expect_under 'a diff with a few real comments' "$repo/sparse.dart" "$(dense 2 40)"
expect_under 'a small diff below the line floor' "$repo/tiny.dart" \
'void main() {' ' // why this retries' '}'
expect_under 'code with no comments at all' "$repo/clean.dart" "$(dense 0 40)"
expect_under 'analyzer directives do not count' "$repo/directives.dart" \
"$(
echo 'void main() {'
for i in $(seq 1 8); do echo ' // ignore: avoid_print'; done
for i in $(seq 1 24); do echo " step$i();"; done
echo '}'
)"
expect_under 'a license header does not count' "$repo/license.dart" \
"$(
for i in $(seq 1 8); do echo '// Copyright 2026 The FlClash Authors.'; done
for i in $(seq 1 24); do echo "final v$i = $i;"; done
)"
expect_under 'go pointer dereferences are not comments' "$repo/deref.go" \
"$(
echo 'package main'
echo 'func f(p *int) {'
for i in $(seq 1 30); do echo ' *p = 1'; done
echo '}'
)"
expect_under 'a generated file is skipped' "$repo/model.g.dart" "$(dense 20 4)"
expect_under 'generated plugin bindings are skipped' \
"$repo/plugins/rust_api/lib/src/rust/frb_generated.io.dart" "$(dense 20 4)"
expect_under 'the intl_utils localization class is skipped' "$repo/lib/l10n/l10n.dart" "$(dense 20 4)"
expect_under 'a vendored upstream file is skipped' "$repo/Clash.Meta/hub.go" "$(dense 20 4)"
expect_under 'a markdown file is skipped' "$repo/notes.md" "$(dense 20 4)"
expect_over 'a dense change to a tracked file' "$repo/tracked.dart" "$(dense 8 24)"
git -C "$repo" checkout --quiet -- tracked.dart
mkdir -p "$repo/lib/nested"
printf '%s\n' "$(dense 8 24)" >"$repo/lib/nested/hooked.dart"
git -C "$repo" add lib/nested/hooked.dart
git -C "$repo" commit --quiet -m 'seed a dense tracked file'
printf 'final addition = 1;\n' >>"$repo/lib/nested/hooked.dart"
set +e
(cd "$repo" && GIT_DIR="$repo/.git" bash "$checker" lib/nested/hooked.dart) \
>"$temp_dir/out" 2>"$temp_dir/err"
status=$?
set -e
if [ "$status" -ne 0 ] || [ -s "$temp_dir/err" ]; then
echo "FAIL: the GIT_DIR a hook exports should not widen the diff to the whole file, got $status" >&2
cat "$temp_dir/err" >&2
failures=$((failures + 1))
fi
git -C "$repo" checkout --quiet -- lib/nested/hooked.dart
printf '%s\n' "$(dense 8 24)" >"$repo/argv.dart"
run_argv "$repo/argv.dart"
if [ "$status" -ne 1 ]; then
echo "FAIL: argv mode should exit 1 when over the cap, got $status" >&2
failures=$((failures + 1))
fi
run_argv "$repo/sparse.dart"
if [ "$status" -ne 0 ]; then
echo "FAIL: argv mode should exit 0 under the cap, got $status" >&2
cat "$temp_dir/err" >&2
failures=$((failures + 1))
fi
set +e
COMMENT_DENSITY_MAX=50 bash "$checker" "$repo/argv.dart" >"$temp_dir/out" 2>"$temp_dir/err"
status=$?
set -e
if [ "$status" -ne 0 ]; then
echo 'FAIL: COMMENT_DENSITY_MAX should raise the cap' >&2
failures=$((failures + 1))
fi
set +e
COMMENT_DENSITY_MAX=1 bash "$checker" "$repo/sparse.dart" >"$temp_dir/out" 2>"$temp_dir/err"
status=$?
set -e
if [ "$status" -ne 1 ]; then
echo 'FAIL: COMMENT_DENSITY_MAX should lower the cap' >&2
failures=$((failures + 1))
fi
# Codex apply_patch payloads carry no file path, so the whole working tree is scanned.
run_stdin
if [ "$status" -ne 2 ]; then
echo "FAIL: a payload without a file path should scan the working tree, got $status" >&2
failures=$((failures + 1))
fi
rm -f \
"$repo/dense.dart" \
"$repo/block.kt" \
"$repo/argv.dart" \
"$repo/plugins/proxy/lib/dense.dart"
run_stdin
if [ "$status" -ne 0 ] || [ -s "$temp_dir/err" ]; then
echo "FAIL: a tree under the cap should pass silently, got $status" >&2
cat "$temp_dir/err" >&2
failures=$((failures + 1))
fi
space_path="$repo/path with space/dense.dart"
mkdir -p "$(dirname "$space_path")"
printf '%s\n' "$(dense 8 24)" >"$space_path"
run_stdin
if [ "$status" -ne 2 ]; then
echo "FAIL: fallback scanning should handle paths with spaces, got $status" >&2
failures=$((failures + 1))
fi
rm -f "$space_path"
if ((failures > 0)); then
echo "$failures check(s) failed" >&2
exit 1
fi
echo 'check_comment_density.sh behaves as documented.'
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
set -euo pipefail
message_file="${1:?commit message file is required}"
# The subject and the body have to be cut from the same text, or they overlap:
# `git commit --cleanup=verbatim` and hook-written templates both leave blank
# lines above the subject, and a body taken as "everything from line two" then
# contains the subject itself — enough for the trailer checks below to read the
# subject as a footer.
cleaned="$(grep -v '^#' "$message_file" | sed '/[^[:space:]]/,$!d' || true)"
subject="$(head -n 1 <<<"$cleaned")"
if [[ -z "$subject" ]]; then
echo 'Commit message is empty.' >&2
exit 1
fi
if [[ "$subject" =~ ^(Merge|Revert)[[:space:]] ]]; then
exit 0
fi
if [[ "$subject" =~ ^(fixup|squash)! ]]; then
exit 0
fi
types='feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert'
if [[ ! "$subject" =~ ^($types)(\([a-z0-9,./_-]+\))?!?:[[:space:]].+ ]]; then
cat >&2 <<EOF
Commit subject does not follow Conventional Commits:
$subject
Expected: <type>[(scope)][!]: <description>
Types: ${types//|/, }
Examples:
perf(views): stop redoing per-frame work in build
refactor(tray): replace the tray_manager fork with a first-party plugin
fix(core,android)!: keep failures visible and lifecycle results honest
EOF
exit 1
fi
if [[ ${#subject} -gt 100 ]]; then
echo "Commit subject is ${#subject} characters; keep it within 100." >&2
exit 1
fi
description="${subject#*: }"
first_word="${description%% *}"
first_word="${first_word%%[^[:alnum:]]*}"
if [[ "$first_word" =~ ^[A-Z][a-z]+$ ]]; then
echo "Commit description should start in lower case: $description" >&2
echo 'Identifiers and acronyms keep their own casing, for example AppBar or DNS.' >&2
exit 1
fi
if [[ "$description" =~ \.$ ]]; then
echo "Commit description should not end with a period: $description" >&2
exit 1
fi
# Changelog trailers. `tool/changelog.dart` reads these to build the user facing
# changelog; the subject is only a fallback.
body="$(tail -n +2 <<<"$cleaned")"
groups='breaking|feat|fix|perf|revert'
while IFS= read -r line; do
if [[ "$line" =~ ^Changelog-([A-Za-z0-9-]+): ]]; then
key="${BASH_REMATCH[1]}"
if [[ "$key" != 'Type' ]]; then
echo "Unknown changelog trailer: Changelog-$key" >&2
echo "The changelog is English only; Changelog-Type is the only" >&2
echo "suffixed trailer. Do not add translations to commit messages." >&2
exit 1
fi
value="${line#*: }"
if [[ ! "$value" =~ ^($groups)$ ]]; then
echo "Unknown Changelog-Type: $value" >&2
echo "Expected one of: ${groups//|/, }" >&2
exit 1
fi
fi
if [[ "$line" =~ ^Breaking-([A-Za-z0-9-]+): ]]; then
echo "Unknown breaking trailer: Breaking-${BASH_REMATCH[1]}" >&2
echo "The changelog is English only; use BREAKING CHANGE: alone." >&2
exit 1
fi
done <<<"$body"
agents='anthropic|claude|codex|copilot|cursor|devin|gemini|openai|\[bot\]'
if grep -qiE "^Co-authored-by:.*($agents)" <<<"$body"; then
cat >&2 <<'EOF'
Do not credit a coding agent in a Co-authored-by trailer.
The history records who owns the change, not which tool typed it. Human
co-authors are still fine.
EOF
exit 1
fi
if [[ "$subject" =~ ^($types)(\([a-z0-9,./_-]+\))?!: ]] &&
! grep -qE '^BREAKING[ -]CHANGE:' <<<"$body"; then
cat >&2 <<'EOF'
A breaking commit needs a BREAKING CHANGE footer describing what breaks:
feat(backup)!: new archive layout
BREAKING CHANGE: Archives from 0.8.95 and earlier need re-import
EOF
exit 1
fi
type="${subject%%[(:!]*}"
if [[ "$type" =~ ^(feat|fix|perf)$ ]] && ! grep -qE '^Changelog:' <<<"$body"; then
cat >&2 <<EOF
Note: no "Changelog:" trailer, so the changelog will reuse this subject.
Changelog: $description
EOF
fi
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
checker="$script_dir/check_commit_msg.sh"
temp_dir="$(mktemp -d)"
trap 'rm -rf "$temp_dir"' EXIT
message_file="$temp_dir/COMMIT_EDITMSG"
failures=0
run() {
printf '%s\n' "$@" >"$message_file"
set +e
bash "$checker" "$message_file" >"$temp_dir/out" 2>"$temp_dir/err"
local status=$?
set -e
return $status
}
expect_pass() {
local name="$1"
shift
if run "$@"; then
return
fi
echo "FAIL: $name should be accepted" >&2
cat "$temp_dir/err" >&2
failures=$((failures + 1))
}
expect_fail() {
local name="$1"
local needle="$2"
shift 2
if run "$@"; then
echo "FAIL: $name should be rejected" >&2
failures=$((failures + 1))
return
fi
if ! grep -q "$needle" "$temp_dir/err"; then
echo "FAIL: $name did not explain the problem ($needle)" >&2
cat "$temp_dir/err" >&2
failures=$((failures + 1))
fi
}
expect_stderr() {
local name="$1"
local needle="$2"
shift 2
expect_pass "$name" "$@"
if ! grep -q "$needle" "$temp_dir/err"; then
echo "FAIL: $name should have warned about $needle" >&2
failures=$((failures + 1))
fi
}
expect_pass 'a conventional subject' 'fix(core): keep the socket alive'
# A message left uncleaned starts with blank lines, and the subject then sits
# below line one. Cutting the body from line two would put the subject in it.
expect_fail 'a leading blank line does not hide a bad trailer' \
'Unknown changelog trailer' \
'' \
'fix(core): keep the socket alive' \
'' \
'Changelog-de: nope'
expect_pass 'a leading blank line still finds the subject' \
'' \
'fix(core): keep the socket alive'
expect_pass 'a merge subject' 'Merge branch main into dev'
expect_pass 'a fixup subject' 'fixup! fix(core): keep the socket alive'
expect_pass 'a changelog trailer' \
'feat(profiles): support override scripts' \
'' \
'Changelog: Per-profile override scripts'
expect_pass 'a breaking commit with its footer' \
'feat(backup)!: new archive layout' \
'' \
'BREAKING CHANGE: Archives from 0.8.95 and earlier need re-import'
expect_pass 'a changelog type override' \
'refactor(tray): replace the fork' \
'' \
'Changelog: Rebuilt tray' \
'Changelog-Type: perf'
expect_fail 'an empty message' 'empty' ''
expect_fail 'a non conventional subject' 'Conventional Commits' 'Optimize core service'
expect_fail 'an upper case description' 'lower case' 'fix(core): Keep the socket alive'
expect_pass 'a camel case identifier' 'fix(ui): AppBar text is truncated'
expect_pass 'an acronym' 'fix(core): DNS leaks after wake'
expect_fail 'an upper case first word before punctuation' 'lower case' \
'fix(core): Keep, or drop, the socket'
expect_fail 'a trailing period' 'period' 'fix(core): keep the socket alive.'
expect_fail 'a changelog translation trailer' 'Changelog-zh-CN' \
'feat: x' '' 'Changelog-zh-CN: 新功能'
expect_fail 'an unknown changelog trailer' 'Changelog-de' \
'feat: x' '' 'Changelog-de: Neu'
expect_fail 'a breaking translation trailer' 'Breaking-zh-CN' \
'feat: x' '' 'Breaking-zh-CN: 变更'
expect_fail 'an unknown changelog type' 'Changelog-Type' \
'chore: x' '' 'Changelog: X' 'Changelog-Type: docs'
expect_fail 'a breaking marker with no footer' 'BREAKING CHANGE' \
'feat(core)!: drop the legacy socket'
expect_fail 'an agent co-author' 'coding agent' \
'fix(core): keep the socket alive' \
'' \
'Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>'
expect_fail 'a bot co-author' 'coding agent' \
'fix(core): keep the socket alive' \
'' \
'Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>'
expect_pass 'a human co-author' \
'fix(core): keep the socket alive' \
'' \
'Co-authored-by: Feng Chen <chen08209@gmail.com>'
expect_stderr 'a user facing commit without a trailer' 'Changelog:' \
'feat(profiles): support override scripts'
if ((failures > 0)); then
echo "$failures check(s) failed" >&2
exit 1
fi
echo 'check_commit_msg.sh behaves as documented.'
+182
View File
@@ -0,0 +1,182 @@
import 'dart:io';
const _defaultReport = 'coverage/lcov.info';
const _excludedPatterns = [
'/generated/',
'lib/l10n/',
'.g.dart',
'.freezed.dart',
];
// Every measured group needs a floor. A group that only answers to the total
// floor can rot for free, because a large well-covered group pays for it: that
// is how `pages`, `plugins` and `lib` reached 20-50% while the total stayed
// green. Adding a top-level directory under `lib/` therefore means adding its
// floor here, and `main()` fails the run until you do.
//
// Floors ratchet up only. Raise one when new tests lift a group; never lower
// one to make a run pass.
const _groupFloors = <String, double>{
'core': 76.0,
'database': 81.0,
'widgets': 82.0,
'features': 81.0,
'models': 67.0,
'providers': 73.0,
'common': 74.0,
'manager': 68.0,
'views': 66.0,
'enum': 86.0,
'pages': 71.0,
'plugins': 67.0,
'lib': 20.0,
};
class _Coverage {
int found = 0;
int hit = 0;
double get percent => found == 0 ? 0 : hit / found * 100;
}
bool _isExcluded(String path) {
final normalized = path.replaceAll(r'\', '/');
return _excludedPatterns.any(normalized.contains);
}
String _group(String path) {
final normalized = path.replaceAll(r'\', '/');
// Anchor on the project's own `lib/`, which is the last one on the path. The
// first match is not it whenever the report carries absolute paths and
// something above the checkout is called `lib` — a clone under `~/dev/lib/`,
// a pub cache entry — and every file then lands in whatever directory
// happened to follow that one.
final int start;
if (normalized.startsWith('lib/')) {
start = 4;
} else {
final index = normalized.lastIndexOf('/lib/');
if (index == -1) {
return 'other';
}
start = index + 5;
}
final relative = normalized.substring(start);
final separator = relative.indexOf('/');
return separator == -1 ? 'lib' : relative.substring(0, separator);
}
void main(List<String> arguments) {
final reportPath = arguments.isNotEmpty ? arguments.first : _defaultReport;
var minimum = 0.0;
if (arguments.length > 1) {
// A typo here used to come back as an unhandled FormatException and a Dart
// stack trace, which reads as a broken tool rather than a wrong argument.
final parsed = double.tryParse(arguments[1]);
if (parsed == null) {
stderr.writeln(
'The total floor must be a number, got "${arguments[1]}".\n'
'Usage: dart run tool/check_coverage.dart [report] [total-floor]',
);
exit(64);
}
minimum = parsed;
}
final report = File(reportPath);
if (!report.existsSync()) {
stderr.writeln('Coverage report not found: $reportPath');
exit(1);
}
final total = _Coverage();
final byGroup = <String, _Coverage>{};
var source = '';
var excluded = false;
for (final line in report.readAsLinesSync()) {
if (line.startsWith('SF:')) {
source = line.substring(3);
excluded = _isExcluded(source);
continue;
}
if (excluded || source.isEmpty) {
continue;
}
final group = byGroup.putIfAbsent(_group(source), _Coverage.new);
if (line.startsWith('LF:')) {
final found = int.parse(line.substring(3));
total.found += found;
group.found += found;
} else if (line.startsWith('LH:')) {
final hit = int.parse(line.substring(3));
total.hit += hit;
group.hit += hit;
}
}
if (total.found == 0) {
stderr.writeln('No measurable lines in $reportPath after exclusions.');
exit(1);
}
final groups = byGroup.entries.toList()
..sort((a, b) => b.value.found.compareTo(a.value.found));
final failures = <String>[];
for (final entry in groups) {
final coverage = entry.value;
final floor = _groupFloors[entry.key];
final below = floor != null && coverage.percent < floor;
if (below) {
failures.add(
'${entry.key} ${coverage.percent.toStringAsFixed(2)}% is below its '
'${floor.toStringAsFixed(2)}% floor.',
);
}
stdout.writeln(
'${entry.key.padRight(12)} '
'${coverage.hit.toString().padLeft(6)}/${coverage.found.toString().padLeft(6)} '
'${coverage.percent.toStringAsFixed(1).padLeft(6)}%'
'${floor == null ? ' (NO FLOOR)' : ' (floor ${floor.toStringAsFixed(0)}%)'}'
'${below ? ' FAIL' : ''}',
);
}
stdout.writeln(
'TOTAL (generated code excluded): '
'${total.hit}/${total.found} ${total.percent.toStringAsFixed(2)}%',
);
final missing = _groupFloors.keys
.where((group) => !byGroup.containsKey(group))
.toList();
for (final group in missing) {
failures.add('$group has a floor but no measured lines in the report.');
}
final unguarded = groups
.map((entry) => entry.key)
.where((group) => !_groupFloors.containsKey(group))
.toList();
for (final group in unguarded) {
failures.add(
'$group is measured but has no floor in _groupFloors; add one at or '
'below its current coverage.',
);
}
if (total.percent < minimum) {
failures.add(
'TOTAL ${total.percent.toStringAsFixed(2)}% is below the '
'${minimum.toStringAsFixed(2)}% floor.',
);
}
if (failures.isEmpty) {
return;
}
for (final failure in failures) {
stderr.writeln(failure);
}
exit(1);
}
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
group() {
if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::group::$1"; else echo "==> $1"; fi
}
endgroup() {
if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::endgroup::"; fi
}
shopt -s nullglob
manifests=(plugins/*/pubspec.yaml)
shopt -u nullglob
if [[ ${#manifests[@]} -eq 0 ]]; then
echo "no plugin packages found under plugins/" >&2
exit 1
fi
analyzed=0
tested=0
for manifest in "${manifests[@]}"; do
package="$(dirname "$manifest")"
group "$package"
(
cd "$package"
flutter pub get
flutter analyze --no-fatal-infos
if compgen -G 'test/*_test.dart' > /dev/null; then
flutter test --reporter expanded
else
echo "no tests in $package"
fi
)
analyzed=$((analyzed + 1))
if compgen -G "$package/test/*_test.dart" > /dev/null; then
tested=$((tested + 1))
fi
endgroup
done
echo "analyzed $analyzed plugin package(s), $tested with tests"
+91 -71
View File
@@ -1,9 +1,16 @@
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'src/icons/ico.dart';
const sourceDir = 'assets_source/images/icon'; const sourceDir = 'assets_source/images/icon';
const outputDir = 'assets/images/icon'; const pngOutputDir = 'assets/images/tray/unix';
const icoOutputDir = 'assets/images/tray/windows';
const statusIconNames = ['status_1', 'status_2', 'status_3']; const statusIconNames = ['status_1', 'status_2', 'status_3'];
const trayBaseSize = 18;
const trayScales = [1, 2, 3, 4];
const appIconSource = 'assets/images/icon.png';
const appIconOutput = 'windows/runner/resources/app_icon.ico';
Future<void> main() async { Future<void> main() async {
final rsvgConvert = await _findExecutable('rsvg-convert'); final rsvgConvert = await _findExecutable('rsvg-convert');
@@ -15,8 +22,9 @@ Future<void> main() async {
return; return;
} }
await Directory(outputDir).create(recursive: true); await Directory(icoOutputDir).create(recursive: true);
final tempDir = await Directory.systemTemp.createTemp('status_icons_'); final tempDir = await Directory.systemTemp.createTemp('status_icons_');
final renderer = _Renderer(rsvgConvert, tempDir);
try { try {
for (final name in statusIconNames) { for (final name in statusIconNames) {
final source = File('$sourceDir/$name.svg'); final source = File('$sourceDir/$name.svg');
@@ -25,29 +33,16 @@ Future<void> main() async {
exitCode = 1; exitCode = 1;
return; return;
} }
await _writeTrayVariants(renderer, source, name);
final png = File('$outputDir/$name.png'); await _writeIco(
final icoPng = File('${tempDir.path}/$name-32.png'); renderer,
final ico = File('$outputDir/$name.ico'); source,
File('$icoOutputDir/$name.ico'),
await _renderSvg( sizes: trayIcoSizes,
rsvgConvert: rsvgConvert,
source: source,
output: png,
width: 108,
height: 108,
); );
await _renderSvg(
rsvgConvert: rsvgConvert,
source: source,
output: icoPng,
width: 32,
height: 32,
);
await ico.writeAsBytes(_buildIco(await icoPng.readAsBytes()));
stdout.writeln('Generated ${png.path} and ${ico.path}');
} }
final appIcon = await renderer.wrapRaster(File(appIconSource));
await _writeIco(renderer, appIcon, File(appIconOutput));
} finally { } finally {
if (tempDir.existsSync()) { if (tempDir.existsSync()) {
await tempDir.delete(recursive: true); await tempDir.delete(recursive: true);
@@ -55,6 +50,37 @@ Future<void> main() async {
} }
} }
Future<void> _writeTrayVariants(
_Renderer renderer,
File source,
String name,
) async {
for (final scale in trayScales) {
final directory = scale == 1 ? pngOutputDir : '$pngOutputDir/$scale.0x';
await Directory(directory).create(recursive: true);
final output = File('$directory/$name.png');
await output.writeAsBytes(
await renderer.render(source, trayBaseSize * scale),
);
stdout.writeln('Generated ${output.path}');
}
}
Future<void> _writeIco(
_Renderer renderer,
File source,
File output, {
List<int> sizes = icoSizes,
}) async {
final entries = [
for (final size in sizes)
IcoEntry(size: size, png: await renderer.render(source, size)),
];
await output.parent.create(recursive: true);
await output.writeAsBytes(buildIco(entries));
stdout.writeln('Generated ${output.path}');
}
Future<String?> _findExecutable(String executable) async { Future<String?> _findExecutable(String executable) async {
final result = await Process.run('which', [executable]); final result = await Process.run('which', [executable]);
if (result.exitCode != 0) { if (result.exitCode != 0) {
@@ -63,53 +89,47 @@ Future<String?> _findExecutable(String executable) async {
return (result.stdout as String).trim(); return (result.stdout as String).trim();
} }
Future<void> _renderSvg({ class _Renderer {
required String rsvgConvert, _Renderer(this.rsvgConvert, this.tempDir);
required File source,
required File output, final String rsvgConvert;
required int width, final Directory tempDir;
required int height, int _sequence = 0;
}) async {
final result = await Process.run(rsvgConvert, [ Future<Uint8List> render(File source, int size) async {
'-w', final output = File('${tempDir.path}/${_sequence++}-$size.png');
'$width', final result = await Process.run(rsvgConvert, [
'-h', '-w',
'$height', '$size',
'-o', '-h',
output.path, '$size',
source.path, '-o',
]); output.path,
if (result.exitCode != 0) { source.path,
stderr ]);
..writeln('Failed to render ${source.path} -> ${output.path}') if (result.exitCode != 0) {
..writeln(result.stderr); stderr
exit(result.exitCode); ..writeln('Failed to render ${source.path} at ${size}px')
..writeln(result.stderr);
exit(result.exitCode);
}
return output.readAsBytes();
} }
}
// librsvg only follows image references inside the SVG's own directory, so
Uint8List _buildIco(List<int> pngBytes) { // the raster is copied next to its wrapper before rendering.
const headerSize = 6; Future<File> wrapRaster(File raster) async {
const directoryEntrySize = 16; final copy = await raster.copy('${tempDir.path}/${_basename(raster)}');
const imageOffset = headerSize + directoryEntrySize; final wrapper = File('${tempDir.path}/${_basename(raster)}.svg');
await wrapper.writeAsString(
final bytes = BytesBuilder(copy: false) '<svg xmlns="http://www.w3.org/2000/svg" '
..add(_uint16(0)) 'xmlns:xlink="http://www.w3.org/1999/xlink" '
..add(_uint16(1)) 'width="256" height="256" viewBox="0 0 256 256">'
..add(_uint16(1)) '<image xlink:href="${_basename(copy)}" width="256" height="256"/>'
..add([32, 32, 0, 0]) '</svg>',
..add(_uint16(1)) );
..add(_uint16(32)) return wrapper;
..add(_uint32(pngBytes.length)) }
..add(_uint32(imageOffset))
..add(pngBytes); String _basename(File file) => file.uri.pathSegments.last;
return bytes.toBytes();
}
Uint8List _uint16(int value) {
return (ByteData(2)..setUint16(0, value, Endian.little)).buffer.asUint8List();
}
Uint8List _uint32(int value) {
return (ByteData(4)..setUint32(0, value, Endian.little)).buffer.asUint8List();
} }
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "$script_dir/.." && pwd)"
cd "$root"
mode=""
want_version=""
do_push=0
assume_yes=0
dry_run=0
usage() {
cat <<'EOF'
Usage: tool/release.sh <pre|stable> [options]
pre Tag the next vX.Y.Z-pre.N prerelease.
stable Write the changelog, commit it, and tag vX.Y.Z.
Options:
--version X.Y.Z Target app version. Defaults to the pubspec version,
bumping the patch when that version is already tagged.
--push Push the branch and the tag once the local steps pass.
--yes Skip the confirmation prompt.
--dry-run Report the plan and exit without changing anything.
-h, --help Show this help.
Examples:
tool/release.sh pre --push # v0.8.97-pre.1, notes rendered by CI
tool/release.sh stable --push # v0.8.97 with a chore(release) commit
EOF
}
die() {
echo "error: $*" >&2
exit 1
}
while (($#)); do
case "$1" in
pre | stable)
[[ -z "$mode" ]] || die "mode given twice: $mode and $1"
mode="$1"
;;
--version)
shift || die "--version needs a value"
want_version="$1"
;;
--version=*) want_version="${1#*=}" ;;
--push) do_push=1 ;;
--yes | -y) assume_yes=1 ;;
--dry-run) dry_run=1 ;;
-h | --help)
usage
exit 0
;;
*)
usage >&2
die "unknown argument: $1"
;;
esac
shift
done
[[ -n "$mode" ]] || {
usage >&2
exit 64
}
if [[ -n "$want_version" && ! "$want_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
die "--version must look like 0.8.97, got: $want_version"
fi
pubspec_version() {
sed -n 's/^version: \([0-9]*\.[0-9]*\.[0-9]*\).*/\1/p' pubspec.yaml
}
if ! git diff --quiet || ! git diff --cached --quiet; then
die "working tree has uncommitted changes; commit or stash them first"
fi
branch="$(git rev-parse --abbrev-ref HEAD)"
[[ "$branch" != "HEAD" ]] || die "detached HEAD; check out a branch first"
if [[ "$branch" != "main" ]]; then
echo "note: releasing from '$branch', not main. CI triggers on the tag, so"
echo " this publishes '$branch' while main stays behind."
fi
current="$(pubspec_version)"
[[ -n "$current" ]] || die "could not read the version from pubspec.yaml"
bumped=0
if [[ -n "$want_version" ]]; then
version="$want_version"
elif git rev-parse -q --verify "refs/tags/v$current" >/dev/null; then
IFS='.' read -r vmaj vmin vpat <<<"$current"
version="$vmaj.$vmin.$((vpat + 1))"
echo "pubspec is at $current, which is already tagged; targeting $version"
else
version="$current"
fi
git rev-parse -q --verify "refs/tags/v$version" >/dev/null &&
die "v$version is already tagged; pass --version with the next one"
if [[ "$version" != "$current" ]]; then
bumped=1
fi
if [[ "$mode" == "pre" ]]; then
last="$(git tag --list "v$version-pre.*" |
sed -n "s/^v$version-pre\.\([0-9]*\)$/\1/p" | sort -n | tail -1)"
tag="v$version-pre.$(((${last:-0}) + 1))"
else
tag="v$version"
fi
echo
echo "mode : $mode"
echo "branch : $branch"
echo "version : $current -> $version$([[ $bumped == 1 ]] && echo ' (pubspec will be rewritten)')"
echo "tag : $tag"
if [[ "$mode" == "pre" ]]; then
echo "changelog : rendered by CI from build --unreleased; nothing committed"
echo "publishes : build artifacts + Telegram only, no GitHub release"
else
echo "changelog : CHANGELOG.md + changelog.json regenerated and committed"
echo "publishes : GitHub release with artifacts, SHA256SUMS, Homebrew cask"
fi
echo "push : $([[ $do_push == 1 ]] && echo yes || echo 'no (printed at the end)')"
echo
committed=0
restore() {
((committed)) && return 0
git checkout -- pubspec.yaml changelog.json CHANGELOG.md 2>/dev/null || true
}
trap restore EXIT
if ((bumped)); then
sed -i.bak "s/^version: [0-9]*\.[0-9]*\.[0-9]*/version: $version/" pubspec.yaml
rm -f pubspec.yaml.bak
fi
tool/bump_version.sh minor
echo "--- release notes for $tag ---"
if dart run tool/changelog.dart build --unreleased >/dev/null 2>&1; then
dart run tool/changelog.dart render release 2>/dev/null |
sed -n '/changelog:begin/,/changelog:end/p' | sed '1d;$d'
else
echo "(preview unavailable: 'build --unreleased' found nothing to collect)"
fi
git checkout -- changelog.json 2>/dev/null || true
echo "--- end of notes ---"
echo
if ((dry_run)); then
echo "dry run: nothing was changed."
exit 0
fi
if ((assume_yes == 0)); then
if [[ ! -r /dev/tty ]]; then
die "no terminal to confirm on; pass --yes to run unattended"
fi
while true; do
read -r -p "Proceed with $tag? [y/n] " reply </dev/tty ||
die "no answer read; pass --yes to run unattended"
case "$reply" in
[yY] | [yY][eE][sS]) break ;;
[nN] | [nN][oO])
echo "Cancelled. Nothing was changed."
exit 0
;;
*) echo "Please answer y or n." ;;
esac
done
fi
if [[ "$mode" == "stable" ]]; then
dart run tool/changelog.dart release --version "$version"
echo
git --no-pager diff --stat -- CHANGELOG.md changelog.json pubspec.yaml
echo
git add CHANGELOG.md changelog.json pubspec.yaml
git commit -m "chore(release): v$version"
committed=1
dart run tool/changelog.dart verify
elif ! git diff --quiet -- pubspec.yaml; then
git add pubspec.yaml
git commit -m "chore: bump version to $(sed -n 's/^version: //p' pubspec.yaml)"
committed=1
else
committed=1
fi
git tag "$tag"
echo "tagged $tag at $(git rev-parse --short HEAD)"
if ((do_push)); then
git push origin "$branch"
git push origin "$tag"
echo "pushed $branch and $tag"
else
echo
echo "Nothing was pushed. To publish:"
echo " git push origin $branch && git push origin $tag"
fi
+190
View File
@@ -0,0 +1,190 @@
import 'dart:convert';
import 'dart:io';
import 'git.dart';
import 'models.dart';
import 'parser.dart';
import 'render.dart';
/// Versions up to and including this tag are frozen: they predate the
/// structured pipeline and are kept verbatim at the bottom of `CHANGELOG.md`.
const frozenBoundaryTag = 'v0.8.96';
const changelogDataPath = 'changelog.json';
const changelogMarkdownPath = 'CHANGELOG.md';
class PendingVersion {
const PendingVersion({
required this.version,
required this.date,
this.prerelease = false,
});
final String version;
final String date;
final bool prerelease;
String get tag => 'v$version';
}
class ChangelogBuildResult {
const ChangelogBuildResult({
required this.changelog,
required this.warnings,
this.commitIdsByTag = const <String, Set<String>>{},
});
final Changelog changelog;
final List<String> warnings;
/// Short hashes of every commit that fed each version, including the ones
/// that produced no entry. `verify` uses it to allow hand written entries as
/// long as they point at a commit that really is in the version's range.
final Map<String, Set<String>> commitIdsByTag;
}
class ChangelogBuilder {
ChangelogBuilder(this.git, {this.boundary = frozenBoundaryTag});
final Git git;
final String boundary;
ChangelogBuildResult build({PendingVersion? pending}) {
final boundaryTag = VersionTag.tryParse(boundary);
final stable = git
.stableTags()
.where((tag) => boundaryTag == null || tag.compareTo(boundaryTag) > 0)
.toList();
final parser = ChangelogParser();
final versions = <ChangelogVersion>[];
final commitIdsByTag = <String, Set<String>>{};
if (pending != null && !git.tagExists(pending.tag)) {
final commits = git.commits(
from: stable.isEmpty ? boundaryTag?.name : stable.first.name,
to: 'HEAD',
);
commitIdsByTag[pending.tag] = _idsOf(commits);
versions.add(
ChangelogVersion(
version: pending.version,
tag: pending.tag,
date: pending.date,
prerelease: pending.prerelease,
groups: groupItems(parser.parseAll(commits)),
),
);
}
for (var index = 0; index < stable.length; index++) {
final tag = stable[index];
final previous = index + 1 < stable.length
? stable[index + 1].name
: boundaryTag?.name;
final commits = git.commits(from: previous, to: tag.name);
commitIdsByTag[tag.name] = _idsOf(commits);
versions.add(
ChangelogVersion(
version: tag.version,
tag: tag.name,
date: git.tagDate(tag.name),
groups: groupItems(parser.parseAll(commits)),
),
);
}
return ChangelogBuildResult(
changelog: Changelog(versions: versions),
warnings: parser.warnings,
commitIdsByTag: commitIdsByTag,
);
}
Set<String> _idsOf(List<RawCommit> commits) =>
commits.map((commit) => commit.shortHash).toSet();
}
final _frozenComment = RegExp(r'\s*<!--.*?-->', dotAll: true);
/// End of the generated head: the frozen marker plus whatever comment lines
/// follow it. Matching comments by shape rather than by text means the note can
/// be reworded without stranding the previous wording in the frozen tail.
int _frozenBoundary(String content) {
final markerIndex = content.indexOf(changelogFrozenMarker);
if (markerIndex < 0) {
return -1;
}
var cursor = markerIndex + changelogFrozenMarker.length;
while (true) {
final match = _frozenComment.matchAsPrefix(content, cursor);
if (match == null) {
return cursor;
}
cursor = match.end;
}
}
String mergeMarkdown(String rendered, String existing) {
final boundary = _frozenBoundary(existing);
if (boundary < 0) {
var tail = existing.trimLeft();
if (tail.startsWith(changelogTitle)) {
tail = tail.substring(changelogTitle.length).trimLeft();
}
return '$rendered$tail';
}
return '$rendered${existing.substring(boundary).trimLeft()}';
}
String markdownHead(String content) {
final boundary = _frozenBoundary(content);
return boundary < 0 ? content : content.substring(0, boundary);
}
/// Tags [derived] has a version for that [recorded] never wrote down.
///
/// This is what catches a release tagged without running `release` first. The
/// entry-by-entry checks in `verify` only look at versions both sides have, so
/// a version missing from `changelog.json` outright would otherwise pass and
/// only surface at the end of the release, when `render` finds no notes for the
/// tag being published.
List<String> missingReleaseTags(Changelog recorded, Changelog derived) {
final known = <String>{for (final version in recorded.versions) version.tag};
return <String>[
for (final version in derived.versions)
if (!known.contains(version.tag)) version.tag,
];
}
String encodeChangelog(Changelog changelog) =>
'${const JsonEncoder.withIndent(' ').convert(changelog.toJson())}\n';
Changelog decodeChangelog(String source) =>
Changelog.fromJson(jsonDecode(source) as Map<String, dynamic>);
String readPubspecVersion(String pubspec) {
final match = RegExp(
r'^version:\s*(\d+\.\d+\.\d+)',
multiLine: true,
).firstMatch(pubspec);
if (match == null) {
throw StateError('No version found in pubspec.yaml');
}
return match.group(1)!;
}
String today() {
final now = DateTime.now();
final month = now.month.toString().padLeft(2, '0');
final day = now.day.toString().padLeft(2, '0');
return '${now.year}-$month-$day';
}
File resolve(String root, String path) {
final separator = Platform.pathSeparator;
if (path.startsWith(separator) || RegExp(r'^[A-Za-z]:[\\/]').hasMatch(path)) {
return File(path);
}
return File('$root$separator$path');
}
+157
View File
@@ -0,0 +1,157 @@
import 'dart:io';
import 'models.dart';
final _tagPattern = RegExp(r'^v(\d+)\.(\d+)\.(\d+)(?:-pre\.(\d+))?$');
const _fieldSeparator = '\u001f';
const _recordSeparator = '\u001e';
/// A release tag that matches the `vMAJOR.MINOR.PATCH[-pre.N]` contract.
///
/// Every other tag in the repository is ignored on purpose: `backup-pre-squash-*`
/// and similar bookkeeping tags used to leak into the generated changelog as
/// fake version sections.
class VersionTag implements Comparable<VersionTag> {
const VersionTag({
required this.name,
required this.major,
required this.minor,
required this.patch,
this.pre,
});
final String name;
final int major;
final int minor;
final int patch;
final int? pre;
bool get isPrerelease => pre != null;
String get version => '$major.$minor.$patch';
static VersionTag? tryParse(String name) {
final match = _tagPattern.firstMatch(name.trim());
if (match == null) {
return null;
}
return VersionTag(
name: name.trim(),
major: int.parse(match.group(1)!),
minor: int.parse(match.group(2)!),
patch: int.parse(match.group(3)!),
pre: match.group(4) == null ? null : int.parse(match.group(4)!),
);
}
@override
int compareTo(VersionTag other) {
final byMajor = major.compareTo(other.major);
if (byMajor != 0) {
return byMajor;
}
final byMinor = minor.compareTo(other.minor);
if (byMinor != 0) {
return byMinor;
}
final byPatch = patch.compareTo(other.patch);
if (byPatch != 0) {
return byPatch;
}
if (pre == null && other.pre == null) {
return 0;
}
if (pre == null) {
return 1;
}
if (other.pre == null) {
return -1;
}
return pre!.compareTo(other.pre!);
}
@override
String toString() => name;
}
class GitException implements Exception {
GitException(this.message);
final String message;
@override
String toString() => message;
}
class Git {
Git({this.workingDirectory});
final String? workingDirectory;
List<VersionTag> versionTags({String revision = 'HEAD'}) {
final output = _run(['tag', '--merged', revision]);
final tags = output
.split('\n')
.map(VersionTag.tryParse)
.whereType<VersionTag>()
.toList();
tags.sort((a, b) => b.compareTo(a));
return tags;
}
List<VersionTag> stableTags({String revision = 'HEAD'}) => versionTags(
revision: revision,
).where((tag) => !tag.isPrerelease).toList();
bool tagExists(String name) =>
_run(['tag', '--list', name]).trim().isNotEmpty;
/// Whether [name] is one of the tags [versionTags] would return.
///
/// [tagExists] answers a different question — a tag on another branch exists
/// but is not derivable here — so callers comparing against a built changelog
/// want this one.
bool tagIsReachable(String name, {String revision = 'HEAD'}) =>
_run(['tag', '--merged', revision, '--list', name]).trim().isNotEmpty;
String tagDate(String name) =>
_run(['log', '-1', '--format=%cs', name]).trim();
String head() => _run(['rev-parse', 'HEAD']).trim();
List<RawCommit> commits({String? from, required String to}) {
final range = from == null ? to : '$from..$to';
final output = _run([
'log',
'--no-merges',
'--format=%H$_fieldSeparator%s$_fieldSeparator%b$_recordSeparator',
range,
]);
return output
.split(_recordSeparator)
.map((record) => record.trim())
.where((record) => record.isNotEmpty)
.map((record) {
final fields = record.split(_fieldSeparator);
return RawCommit(
hash: fields[0].trim(),
subject: fields.length > 1 ? fields[1] : '',
body: fields.length > 2 ? fields[2] : '',
);
})
.toList();
}
String _run(List<String> arguments) {
final result = Process.runSync(
'git',
arguments,
workingDirectory: workingDirectory,
);
if (result.exitCode != 0) {
throw GitException('git ${arguments.join(' ')} failed: ${result.stderr}');
}
return result.stdout as String;
}
}
+159
View File
@@ -0,0 +1,159 @@
library;
const changelogSchemaVersion = 2;
enum ChangelogType {
breaking('breaking', 'Breaking Changes'),
feat('feat', 'Features'),
fix('fix', 'Bug Fixes'),
perf('perf', 'Performance'),
revert('revert', 'Reverts');
const ChangelogType(this.id, this.title);
final String id;
final String title;
static ChangelogType? fromId(String value) {
for (final type in values) {
if (type.id == value) {
return type;
}
}
return null;
}
}
class RawCommit {
const RawCommit({
required this.hash,
required this.subject,
required this.body,
});
final String hash;
final String subject;
final String body;
String get shortHash => hash.length > 7 ? hash.substring(0, 7) : hash;
}
class ChangelogEntry {
const ChangelogEntry({required this.id, required this.text, this.scope});
final String id;
final String? scope;
final String text;
Map<String, dynamic> toJson() => <String, dynamic>{
'id': id,
if (scope != null) 'scope': scope,
'text': text,
};
static ChangelogEntry fromJson(Map<String, dynamic> json) => ChangelogEntry(
id: json['id'] as String,
scope: json['scope'] as String?,
text: json['text'] as String,
);
}
class ChangelogGroup {
const ChangelogGroup({required this.type, required this.entries});
final ChangelogType type;
final List<ChangelogEntry> entries;
Map<String, dynamic> toJson() => <String, dynamic>{
'type': type.id,
'entries': entries.map((entry) => entry.toJson()).toList(),
};
static ChangelogGroup? fromJson(Map<String, dynamic> json) {
final type = ChangelogType.fromId(json['type'] as String);
if (type == null) {
return null;
}
return ChangelogGroup(
type: type,
entries: (json['entries'] as List<dynamic>)
.map(
(entry) => ChangelogEntry.fromJson(entry as Map<String, dynamic>),
)
.toList(),
);
}
}
class ChangelogVersion {
const ChangelogVersion({
required this.version,
required this.tag,
required this.date,
required this.groups,
this.prerelease = false,
});
final String version;
final String tag;
final String date;
final bool prerelease;
final List<ChangelogGroup> groups;
bool get isEmpty => groups.every((group) => group.entries.isEmpty);
Map<String, dynamic> toJson() => <String, dynamic>{
'version': version,
'tag': tag,
'date': date,
'prerelease': prerelease,
'groups': groups.map((group) => group.toJson()).toList(),
};
static ChangelogVersion fromJson(Map<String, dynamic> json) =>
ChangelogVersion(
version: json['version'] as String,
tag: json['tag'] as String,
date: json['date'] as String,
prerelease: json['prerelease'] as bool? ?? false,
groups: (json['groups'] as List<dynamic>)
.map(
(group) => ChangelogGroup.fromJson(group as Map<String, dynamic>),
)
.whereType<ChangelogGroup>()
.toList(),
);
}
class Changelog {
const Changelog({
required this.versions,
this.schemaVersion = changelogSchemaVersion,
});
final int schemaVersion;
final List<ChangelogVersion> versions;
static const empty = Changelog(versions: <ChangelogVersion>[]);
Map<String, dynamic> toJson() => <String, dynamic>{
'schemaVersion': schemaVersion,
'versions': versions.map((version) => version.toJson()).toList(),
};
static Changelog fromJson(Map<String, dynamic> json) {
final schemaVersion = json['schemaVersion'] as int? ?? 0;
if (schemaVersion != changelogSchemaVersion) {
return empty;
}
return Changelog(
schemaVersion: schemaVersion,
versions: (json['versions'] as List<dynamic>)
.map(
(version) =>
ChangelogVersion.fromJson(version as Map<String, dynamic>),
)
.toList(),
);
}
}
+200
View File
@@ -0,0 +1,200 @@
import 'models.dart';
class ChangelogItem {
const ChangelogItem({required this.type, required this.entry});
final ChangelogType type;
final ChangelogEntry entry;
}
const _skipMarker = 'skip';
const _defaultTypes = <String, ChangelogType>{
'feat': ChangelogType.feat,
'fix': ChangelogType.fix,
'perf': ChangelogType.perf,
'revert': ChangelogType.revert,
};
final _subjectPattern = RegExp(r'^([a-z]+)(?:\(([^)]*)\))?(!)?:[ \t]+(.+)$');
final _trailerPattern = RegExp(
r'^(BREAKING[ -]CHANGE|Changelog(?:-[A-Za-z0-9-]+)?|Breaking-[A-Za-z0-9-]+):[ \t]*(.*)$',
);
/// Any line shaped like a git trailer: a hyphenated key (`Co-authored-by`,
/// `Signed-off-by`, `Reviewed-by`, `Change-Id`, ...) or one of the known
/// unhyphenated trailers. Trailers we don't recognize still end the trailer
/// being accumulated, so e.g. a `Co-authored-by:` line after a `Changelog:`
/// footer isn't glued onto the changelog text as a continuation line. Plain
/// prose like `Note: ...` has neither shape and stays a continuation line.
final _genericTrailerPattern = RegExp(
r'^(?:[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+|Refs|Fixes|Closes):[ \t]*',
caseSensitive: false,
);
/// The only suffixed trailer left. Translations were removed on purpose: the
/// changelog ships English, and anything else belongs in a post-processing
/// step, not in the commit message.
const _knownSuffixedTrailer = 'Changelog-Type';
/// The parser is pure: it never touches git or the filesystem, so every rule
/// below is covered by `test/tool/changelog_parser_test.dart`.
class ChangelogParser {
final List<String> warnings = <String>[];
List<ChangelogItem> parseAll(List<RawCommit> commits) =>
commits.expand(parse).toList();
List<ChangelogItem> parse(RawCommit commit) {
final match = _subjectPattern.firstMatch(commit.subject.trim());
if (match == null) {
warnings.add(
'${commit.shortHash} is not a conventional commit: ${commit.subject}',
);
return const <ChangelogItem>[];
}
final type = match.group(1)!;
final scope = match.group(2);
final bang = match.group(3) != null;
final description = match.group(4)!.trim();
final trailers = _readTrailers(commit);
final changelog = trailers['Changelog'];
if (changelog != null && changelog.toLowerCase() == _skipMarker) {
return const <ChangelogItem>[];
}
final items = <ChangelogItem>[];
final breakingText = trailers['BREAKING CHANGE'];
if (bang || breakingText != null) {
if (breakingText == null) {
warnings.add(
'${commit.shortHash} is marked breaking but has no '
'"BREAKING CHANGE:" footer; using the subject instead.',
);
}
items.add(
ChangelogItem(
type: ChangelogType.breaking,
entry: ChangelogEntry(
id: commit.shortHash,
scope: _normalizeScope(scope),
text: _text(breakingText, description),
),
),
);
}
final group = _resolveType(type, trailers, commit);
if (group != null) {
items.add(
ChangelogItem(
type: group,
entry: ChangelogEntry(
id: commit.shortHash,
scope: _normalizeScope(scope),
text: _text(changelog, description),
),
),
);
}
return items;
}
ChangelogType? _resolveType(
String type,
Map<String, String> trailers,
RawCommit commit,
) {
final override = trailers['Changelog-Type'];
if (override != null) {
final resolved = ChangelogType.fromId(override);
if (resolved == null) {
warnings.add(
'${commit.shortHash} has an unknown Changelog-Type: $override',
);
}
return resolved;
}
final byType = _defaultTypes[type];
if (byType != null) {
return byType;
}
return trailers.containsKey('Changelog') ? ChangelogType.feat : null;
}
/// The trailer wins over the subject, but only when it carries text: an
/// empty `Changelog:` used to blank the entry out instead of falling back.
String _text(String? trailer, String description) =>
trailer == null || trailer.isEmpty ? _capitalize(description) : trailer;
Map<String, String> _readTrailers(RawCommit commit) {
final trailers = <String, String>{};
String? currentKey;
for (final line in commit.body.split('\n')) {
final match = _trailerPattern.firstMatch(line);
if (match != null) {
currentKey = match
.group(1)!
.replaceAll('BREAKING-CHANGE', 'BREAKING CHANGE');
_warnUnknownTrailer(currentKey, commit);
trailers[currentKey] = match.group(2)!.trim();
continue;
}
if (currentKey == null) {
continue;
}
if (_genericTrailerPattern.hasMatch(line)) {
currentKey = null;
continue;
}
final continuation = line.trim();
if (continuation.isEmpty) {
currentKey = null;
continue;
}
trailers[currentKey] = '${trailers[currentKey]} $continuation'.trim();
}
return trailers;
}
void _warnUnknownTrailer(String key, RawCommit commit) {
if (key == _knownSuffixedTrailer || !key.contains('-')) {
return;
}
warnings.add(
'${commit.shortHash} uses an unsupported changelog trailer: $key. '
'The changelog is English only; $_knownSuffixedTrailer is the only '
'suffixed trailer left.',
);
}
}
List<ChangelogGroup> groupItems(List<ChangelogItem> items) {
final groups = <ChangelogGroup>[];
for (final type in ChangelogType.values) {
final entries = items
.where((item) => item.type == type)
.map((item) => item.entry)
.toList();
if (entries.isNotEmpty) {
groups.add(ChangelogGroup(type: type, entries: entries));
}
}
return groups;
}
String? _normalizeScope(String? scope) {
final trimmed = scope?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}
String _capitalize(String value) {
if (value.isEmpty) {
return value;
}
return value[0].toUpperCase() + value.substring(1);
}
+136
View File
@@ -0,0 +1,136 @@
import 'dart:convert';
import 'models.dart';
const changelogTitle = '# Changelog';
/// Everything below this marker predates the structured pipeline and is kept
/// verbatim. The generator never writes past it.
const changelogFrozenMarker = '<!-- changelog:frozen -->';
const changelogFrozenNote =
'<!-- Entries below predate the structured pipeline. Their wording is kept as '
'written; only the heading and list style were normalized. -->';
const releaseBeginMarker = '<!-- flclash:changelog:begin -->';
const releaseEndMarker = '<!-- flclash:changelog:end -->';
/// Opens the HTML comment that carries the structured entries to the app. The
/// update dialog reads them from the release GitHub already returns, so the
/// notes cost no request of their own and stay invisible on the release page.
const releaseJsonBeginMarker = '<!-- flclash:changelog:json';
const releaseJsonEndMarker = '-->';
const telegramLimit = 900;
const emptyVersionNote = 'Internal improvements only.';
String renderMarkdown(Changelog changelog) {
final buffer = StringBuffer()
..writeln(changelogTitle)
..writeln();
for (final version in changelog.versions) {
buffer
..writeln(_heading(version))
..writeln();
if (version.isEmpty) {
buffer
..writeln(emptyVersionNote)
..writeln();
}
for (final group in version.groups) {
buffer
..writeln('**${group.type.title}**')
..writeln();
for (final entry in group.entries) {
buffer.writeln('- ${_prefix(entry)}${entry.text} (${entry.id})');
}
buffer.writeln();
}
}
buffer
..writeln(changelogFrozenMarker)
..writeln(changelogFrozenNote)
..writeln();
return buffer.toString();
}
String renderRelease(ChangelogVersion version) {
final buffer = StringBuffer()..writeln(releaseBeginMarker);
if (version.isEmpty) {
buffer.writeln('- $emptyVersionNote');
}
for (final group in version.groups) {
buffer.writeln('### ${group.type.title}');
for (final entry in group.entries) {
buffer.writeln('- ${_prefix(entry)}${entry.text}');
}
buffer.writeln();
}
buffer
..writeln(releaseEndMarker)
..writeln()
..write(renderReleaseJson(version));
return buffer.toString();
}
/// The same version as machine readable JSON, wrapped in an HTML comment so it
/// travels with the release body without showing up on the release page.
///
/// `>` is escaped so no entry can close the comment early, which keeps a
/// changelog line containing `-->` from truncating the payload.
String renderReleaseJson(ChangelogVersion version) {
final payload = jsonEncode(
Changelog(versions: <ChangelogVersion>[version]).toJson(),
).replaceAll('>', r'\u003e');
return '$releaseJsonBeginMarker\n$payload\n$releaseJsonEndMarker\n';
}
/// The caption is sent with a parse mode, so entry text is markup, not text: a
/// commit subject carrying a `<` used to be a malformed tag and Telegram
/// rejected the whole upload. HTML rather than Markdown because it is the one
/// Telegram parse mode with a defined escape — legacy Markdown has none, and a
/// subject containing a lone `*` or `_` cannot be made safe in it.
String escapeTelegramHtml(String value) => value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
/// Telegram captions are capped at 1024 characters, so the text is truncated
/// with a pointer to the full notes instead of failing the upload.
String renderTelegram(ChangelogVersion version, {required String moreUrl}) {
final buffer = StringBuffer();
if (version.isEmpty) {
buffer.writeln('• ${escapeTelegramHtml(emptyVersionNote)}');
}
for (final group in version.groups) {
buffer.writeln('<b>${escapeTelegramHtml(group.type.title)}</b>');
for (final entry in group.entries) {
buffer.writeln('• ${escapeTelegramHtml(entry.text)}');
}
buffer.writeln();
}
final text = buffer.toString().trimRight();
if (text.length <= telegramLimit) {
return text;
}
final cut = text.substring(0, telegramLimit);
final lastBreak = cut.lastIndexOf('\n');
// Cut on a line break so the cut never lands inside a tag; the trailing
// pattern covers the one case that has no break to cut on, where the cut can
// still land halfway through an entity.
final kept = (lastBreak < 0 ? cut : cut.substring(0, lastBreak)).replaceAll(
RegExp(r'&[A-Za-z]*$|<[^>]*$'),
'',
);
return '$kept\n\n…\n$moreUrl';
}
/// Version headings stay the only `##` level; groups are bold lines because a
/// release carries a handful of entries and an `###` per group outweighs them.
String _heading(ChangelogVersion version) => version.date.isEmpty
? '## ${version.tag}'
: '## ${version.tag} (${version.date})';
String _prefix(ChangelogEntry entry) =>
entry.scope == null ? '' : '**${entry.scope}** ';
+53
View File
@@ -0,0 +1,53 @@
import 'dart:typed_data';
final class IcoEntry {
const IcoEntry({required this.size, required this.png});
final int size;
final Uint8List png;
}
const _headerSize = 6;
const _directoryEntrySize = 16;
/// Every size the Windows shell requests, so no glyph is resampled from a far neighbour.
const icoSizes = [16, 20, 24, 32, 40, 48, 64, 96, 128, 256];
/// The tray only ever asks for `SM_CXSMICON` (16..32) plus a 2x headroom.
const trayIcoSizes = [16, 20, 24, 32, 40, 48, 64];
Uint8List buildIco(List<IcoEntry> entries) {
if (entries.isEmpty) {
throw ArgumentError.value(entries, 'entries', 'must not be empty');
}
final sorted = [...entries]..sort((a, b) => a.size.compareTo(b.size));
final directory = BytesBuilder(copy: false)
..add(_uint16(0))
..add(_uint16(1))
..add(_uint16(sorted.length));
final images = BytesBuilder(copy: false);
var offset = _headerSize + _directoryEntrySize * sorted.length;
for (final entry in sorted) {
if (entry.size < 1 || entry.size > 256) {
throw ArgumentError.value(entry.size, 'size', 'must be 1..256');
}
final dimension = entry.size == 256 ? 0 : entry.size;
directory
..add([dimension, dimension, 0, 0])
..add(_uint16(1))
..add(_uint16(32))
..add(_uint32(entry.png.length))
..add(_uint32(offset));
images.add(entry.png);
offset += entry.png.length;
}
return Uint8List.fromList([...directory.toBytes(), ...images.toBytes()]);
}
Uint8List _uint16(int value) {
return (ByteData(2)..setUint16(0, value, Endian.little)).buffer.asUint8List();
}
Uint8List _uint32(int value) {
return (ByteData(4)..setUint32(0, value, Endian.little)).buffer.asUint8List();
}
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
usage: tool/worktrees.sh list | prune [--force]
list Show every worktree with its owner tool and whether it has uncommitted changes.
prune Remove worktrees that are clean, detached or locked-by-nobody, and not the main checkout.
Dirty worktrees are listed and kept; pass --force to remove them as well.
USAGE
}
owner_of() {
case "$1" in
*/.claude/worktrees/*) echo claude ;;
*/.codex/worktrees/*) echo codex ;;
*/.gemini/*) echo gemini ;;
*) echo other ;;
esac
}
root="$(git rev-parse --show-toplevel)"
each_worktree() {
git -C "$root" worktree list --porcelain | awk '/^worktree /{print substr($0, 10)}'
}
is_locked() {
git -C "$root" worktree list --porcelain | awk -v p="$1" '$0=="worktree " p{f=1; next} /^worktree /{f=0} f && /^locked/{found=1} END{exit !found}'
}
state_of() {
local path="$1"
if [ ! -d "$path" ]; then
echo missing
elif [ -n "$(git -C "$path" status --porcelain 2>/dev/null)" ]; then
echo dirty
else
echo clean
fi
}
cmd="${1:-list}"
force=0
[ "${2:-}" = "--force" ] && force=1
case "$cmd" in
list)
printf '%-8s %-8s %s\n' OWNER STATE PATH
while IFS= read -r path; do
printf '%-8s %-8s %s\n' "$(owner_of "$path")" "$(state_of "$path")" "$path"
done < <(each_worktree)
;;
prune)
git -C "$root" worktree prune
while IFS= read -r path; do
[ "$path" = "$root" ] && continue
if is_locked "$path"; then
echo "keep (locked): $path"
continue
fi
state="$(state_of "$path")"
if [ "$state" = dirty ] && [ "$force" -eq 0 ]; then
echo "keep (dirty): $path"
continue
fi
git -C "$root" worktree remove --force "$path" && echo "removed: $path"
done < <(each_worktree)
git -C "$root" worktree prune
;;
*)
usage
exit 1
;;
esac