Compare commits

...

11 Commits

Author SHA1 Message Date
chen08209 9caebbe374 chore: bump version to 0.8.99+2026092601 2026-09-26 16:52:11 +08:00
chen08209 643af90736 feat(editor): move code_forge into the repository and add snippets
Changelog: Editor word wrap, completion with Clash and script snippets, and a context menu that no longer blocks selection
2026-09-26 16:49:10 +08:00
chen08209 21f9633637 feat(dashboard): add cards and an animated, editable grid
Changelog: More dashboard cards, and an add sheet that stays open between adds
2026-09-26 16:49:09 +08:00
chen08209 4973d7e17e feat(ui): add a sidebar, floating dock, snap sheets and the app's own glyphs
Drops the Material compatibility bridge and defer_pointer.

Changelog: A sidebar, a floating dock or docked bottom bar, snapping sheets, drag to go back, and a redrawn theme page
2026-09-26 16:49:09 +08:00
chen08209 9c94bacc33 feat(overwrite): add app-level providers, scripts and validated custom proxies
Providers and custom proxies stay behind feature flags.

Changelog: Custom overrides point out invalid proxies and rules, and added rules get quick-add presets
2026-09-26 16:49:08 +08:00
chen08209 0c2a55c3d8 feat(config): log DNS queries, override only picked keys and rework settings
TUN now defaults to the mips stack.

Changelog: A DNS query log, DNS and NTP overrides that change only picked keys, a General settings page, and more hotkeys
2026-09-26 16:49:08 +08:00
chen08209 ecde746d88 fix(backup,database): stage restores and enforce foreign keys
Changelog: Restores no longer leave a half-applied backup behind
2026-09-26 16:49:08 +08:00
chen08209 c67f651eae feat(core): check services and the outbound IP through the Core
Changelog: Service checks run through the proxy, and the outbound IP shows its quality and risk
2026-09-26 16:49:07 +08:00
chen08209 a4b7737d02 feat(platform): move desktop windows to plugins/window and rework the tray
plugins/window replaces the window_manager fork on every desktop.

Changelog: The tray shows hotkeys, node delays and a delay test
2026-09-26 16:49:07 +08:00
chen08209 ab56730381 docs(agents): correct stale facts and sharpen skill triggers 2026-09-26 16:49:06 +08:00
chen08209 b560ed2e49 build: target Android SDK 37, use Rust 1.98.1 and add a safe mode run
Moves to AGP 9.4.1 and Gradle 9.8.0; Rust 1.98.1 lets macOS 27 load
rust_api.
2026-09-26 16:49:06 +08:00
703 changed files with 99966 additions and 15424 deletions
+4 -4
View File
@@ -5,11 +5,11 @@ Use the smallest durable surface that matches the job.
## Surfaces
- `AGENTS.md`: repository entry point. Keep it small and reserve it for always-on rules, routing, and high-priority expectations.
- `CLAUDE.md`: the `@AGENTS.md` import and nothing that is not Claude-only. Claude Code loads `CLAUDE.md` into the system prompt at startup; the import makes `AGENTS.md` arrive with it. Without it, `AGENTS.md` reaches the session only as a tool result some agent chose to fetch, which is the weakest position in context and the first thing dropped when the conversation is summarized. Do not let rules accumulate in this file.
- No `CLAUDE.md`: Claude Code v2.1.277+ loads `AGENTS.md` at startup only while no `CLAUDE.md`, `.claude/CLAUDE.md`, or `CLAUDE.local.md` sits in the working directory or above it, so adding one silently replaces `AGENTS.md` for Claude. Sessions that cannot load `AGENTS.md` directly (third-party providers, telemetry disabled) need an uncommitted `CLAUDE.local.md` containing `@AGENTS.md`. The one nested `CLAUDE.md`, in `plugins/rust_api/`, imports the root `AGENTS.md` for the same reason.
- `.agents/*.md`: human- and agent-readable reference docs linked from `AGENTS.md`. Use these for detailed project context, commands, architecture, and conventions.
- `.agents/skills/*/SKILL.md`: repo-scoped Codex skills. Use these for repeatable workflows that should be discoverable by name and description.
- `.agents/skills/*/SKILL.md`: repo skills shared by Codex and Claude Code. Use these for repeatable workflows that should be discoverable by name and description; `.claude/skills/` holds one symlink per skill so Claude Code finds the same files.
- `.codex/config.toml`: trusted project Codex settings such as MCP, hooks, sandbox, approval, or model defaults.
- `.codex/rules/*.rules`: command permission rules for running commands outside the sandbox. Do not mix these with coding conventions in `.agents/rules.md`.
- `.codex/rules/*.rules`: command permission rules for running commands outside the sandbox. None exist yet. Do not mix these with coding conventions in `.agents/rules.md`.
- `.codex/hooks.json` or `.codex/config.toml` hooks: lifecycle automation such as prompt checks, command validation, or final verification gates.
## Placement Rules
@@ -19,7 +19,7 @@ Use the smallest durable surface that matches the job.
- Put reusable task workflows in `.agents/skills/<skill-name>/SKILL.md`.
- Put mechanical enforcement in linters, tests, hooks, or `.codex/rules`; do not rely on prose when tooling can enforce the rule.
- Split a rule into the part that can be counted and the part that cannot, and enforce them differently. A rule resting entirely on judgment gets decided at the keyboard, at the moment the exception looks most justified; the comment policy is the worked example, where "is this comment useful" stays prose and review while "are these comments too frequent" became a measured gate. Find the countable half before writing more prose.
- Restate a rule verbatim in every context that must obey it, and prefer a runnable check to a description of the rule. A gate an agent trips before reporting done outperforms a paragraph asking it to have been careful.
- Restate the one-line form of a rule in every context that must obey it, and keep its explanation in one `.agents/*.md` file that the others link to. Prefer a runnable check to either: a gate an agent trips before reporting done outperforms a paragraph asking it to have been careful.
- State a gate's real force. Enforcement differs per tool, and a rule that claims uniform coverage it does not have is worse than one that names the gap.
- Keep user-specific preferences out of the repository. They belong in user-level Codex configuration or user-level skills.
+389 -80
View File
@@ -17,10 +17,10 @@ Desktop core mode:
- Go core runs as a separate process with `CGO_ENABLED=0`.
- `rust_api`'s `ipc` module provides the native local-IPC primitives: a Unix domain socket on macOS/Linux and a named pipe
on Windows.
Dart now owns the transport state, RPC correlation, process ownership, and lifecycle convergence above those primitives.
on Windows. Dart owns the transport state, RPC correlation, process ownership, and lifecycle convergence above those
primitives.
- `lib/core/service.dart` (`CoreService`) is the composition root. It wires the IPC transport, launcher selection,
lifecycle controller, RPC client, and crash-event bridge; it is no longer the whole desktop implementation by itself.
lifecycle controller, RPC client, and crash-event bridge, and holds no lifecycle logic of its own.
- `lib/core/desktop/transport.dart` converts native IPC frames into ready, connected, disconnected, failed, and data
events. A replaceable binding keeps RPC subscriptions stable when a failed or stale transport must be rebuilt.
- `lib/core/desktop/rpc_client.dart` owns request IDs and pending completers, waits up to 10 seconds for a connection,
@@ -36,10 +36,45 @@ Key Go core files:
- `core/hub.go`: handler functions.
- `core/method.go`: MethodChannel-style method-call dispatch and response envelopes.
- `core/message.go`: non-blocking priority/bulk event queues and bounded message batching.
- `core/message.go`: non-blocking state/priority/bulk event queues and bounded message batching.
- `core/lib.go`: CGO exports.
- `core/server.go`: desktop socket/named-pipe client and framed message forwarding.
## DNS Override
`PatchClashConfig.dns` holds a full `Dns` model, but only the keys listed in `dnsOverrideKeys` reach a profile.
`DnsOverrideKey` names one Clash key each, with `fallback-filter.*` entries for the nested filter, and covers every
yaml key of mihomo's `RawDNS` and `RawFallbackFilter`; `test/models/dns_override_test.dart` reads
`core/Clash.Meta/config/config.go` and fails when a core bump adds or drops one. `Dns.overrideJson` builds the
fragment in model order and `mergeDnsOverride` merges it one level deep, so a partial fallback filter keeps the
profile's other filter keys. `_makeRealProfileTask` applies the fragment when `overrideDns` is on; for a profile whose
`dns.enable` is not true it first fills `defaultDns` for `baselineDnsOverrideKeys` and then applies the fragment
whatever the toggle says. The DNS page's quick edit opens `overrideYaml` in an `EditorPage` and applies it on the way
back through `applyOverrideYaml`, which rebuilds the key set from the keys the document names and rejects unknown keys
or bad values; a rejected document can only be discarded or fixed. The page has no reset, because there is nothing to
reset to.
A fresh config overrides nothing: `dnsOverrideKeys` starts empty and the profile's own section stands until an entry
is added. `baselineDnsOverrideKeys` is the separate, smaller thing the core is never without — the `enable`,
`enhanced-mode: fake-ip` and `nameserver` given to a profile that brings no enabled DNS section of its own, so the
core always resolves. Every other `Dns` field starts at mihomo's own default, which `UnmarshalRawConfig` starts from,
so adding an entry changes nothing until it is edited; `listen` and `fake-ip-range6`, which mihomo leaves off, start
from a working value instead. A config saved before the key list existed overrode the whole section as the model held
it then, so `PatchClashConfig.fromJson` gives it those twenty keys rather than the empty set.
## NTP Override
`PatchClashConfig.ntp` mirrors the DNS shape: a full `Ntp` model of which only the keys in `ntpOverrideKeys` reach a
profile, an empty set on a fresh config, and one `NtpOverrideKey` per yaml key of mihomo's `RawNTP`;
`test/models/ntp_override_test.dart` reads `core/Clash.Meta/config/config.go` and fails when a core bump adds, drops
or redefaults one. The section is flat, so `_makeRealProfileTask` spreads the fragment over the profile's own `ntp`
map instead of the one-level merge DNS needs, and writes nothing at all while `overrideNtp` is off. `interval` is
minutes, which is what `ReCreateNTPService` multiplies. The page carries the same add sheet and quick edit as DNS.
Safe mode forces `write-to-system` off on whatever `ntp` section the profile carries, because setting the system clock
is a host change; mihomo's own `loadConfig` already drops it on Android, where the seccomp policy kills the process
for `settimeofday`.
## Listener Exposure
`_makeRealProfileTask` in `lib/common/task.dart` writes `mixed-port`, `allow-lan`, and `external-controller` into every
@@ -49,7 +84,7 @@ generated profile. With `allow-lan` off, mihomo's `genAddr` binds each listener
The loopback listener takes local connections without authentication by default: mandatory credentials would lock out
every external local consumer a proxy client exists for. The binding only guarantees traffic originates on the device;
it does not isolate programs already running there, so on Android any app holding `INTERNET` can use the proxy and learn
the outbound IP (#1934). The opt-in answer is the local authentication setting (`NetworkProps.localAuth`), which spans
the outbound IP (#1934). The opt-in answer is the local authentication setting (`NetworkProps.authentication`), which spans
four paths that must stay in sync: `_makeRealProfileTask` writes the credentials into `authentication` and force-clears
`skip-auth-prefixes` so a profile cannot silently exempt loopback; `UpdateParams.authentication` applies the same list
to a running core; `FlClashHttpOverrides` sends the credentials with the app's own proxied requests; and `sharedState`
@@ -76,9 +111,9 @@ callers must not try to reuse a closed platform implementation.
- `startCore()` publishes `connecting`, starts the platform Core, publishes `connected`, then initializes Core state. A
startup error publishes `disconnected` and displays the error.
- `restartCore()` coalesces overlapping callers behind one worker. `_requestedRestartRevision` records newer requests,
while `_latestExplicitStart` retains the newest requested post-restart running intent. After the lifecycle restart and
`initCore()`, the worker reapplies profile/running state until it has consumed the latest revision.
- `restartCore()` coalesces overlapping callers behind one worker. `_requestedRestartRevision` records newer requests.
After the lifecycle restart, the worker reapplies running state or the profile, rereading `isStartProvider` on every
pass, until it has consumed the latest revision, so the newest start intent wins.
- The provider is an orchestration and presentation layer, not a process owner. Platform lifecycle code remains responsible
for determining whether a Core process/service is actually running.
@@ -156,12 +191,26 @@ remain strings.
Go event delivery is intentionally non-blocking:
- State-bearing events such as delay, loaded-provider, and geo-update use a 256-entry priority queue. Desktop process
crashes are generated locally by `CoreService` from lifecycle failures rather than sent through the Go queue.
- High-volume log and request/connection events use a separate 256-entry bulk queue, so bulk floods cannot evict state.
- A full queue evicts only its own oldest event and retries the newest event; Core work never blocks on event delivery.
- Loaded-provider, geo-update, and route-changed events use a state queue that never evicts; the rules for it are under
Core API Safety in `.agents/rules.md`. Every other event except the bulk kinds, delay included, uses a 256-entry
priority queue. Desktop process crashes are generated locally by `CoreService` from lifecycle failures rather than
sent through the Go queue.
- High-volume log, request/connection, and DNS query events use a separate 256-entry bulk queue, so bulk floods cannot
evict state.
- A full priority or bulk queue evicts only its own oldest event and retries the newest; DNS events never evict. Core
work never blocks on event delivery.
- The batcher flushes at 32 messages or every 16 milliseconds. Priority events are preferred, but one bulk opportunity is
guaranteed after eight priority messages to prevent starvation.
- `sendMessage` drops an event before it is queued when `hasEventListener` is false. That only happens on Android, after
the Flutter engine detaches while the service keeps the Core running; request and DNS events would otherwise be
encoded per connection and per query for nobody.
DNS query events come from the Clash.Meta fork's `dns/patch.go` and are raised where mihomo writes its `[DNS]` debug
log, so the DNS page lists the same resolutions: one event per cache hit, and one per upstream exchange however many
callers singleflight merged into it, carrying the upstream whose answer was used. Fake-IP and hosts answers never reach
a resolver and are not recorded. Queries from apps arrive with the `DNSContext` that `Service.ServeMsg` created and
report `app`; other initiators come from `resolver.WithInitiator` at the call site (rule matching, DIRECT, proxy
server dialing), and unlabeled queries report `other`.
Desktop RPC accepts both a single event object and batched event lists. Android and desktop listener dispatch isolate
listener exceptions so one faulty observer does not prevent the remaining events/listeners from running.
@@ -179,20 +228,135 @@ surface. It is shown only outside dashboard edit mode and only when `coreLib ==
- Taps during the display hold or while the provider is genuinely connecting are inert. Connected/disconnected taps show
the appropriate confirmation and delegate restart to `CoreAction`; the widget never starts Core directly.
Proxy delay testing follows the same failure-safe UI rule. `proxyDelayTest()` records an in-progress zero delay, writes the
real result on success, and logs plus records `-1` on exceptions. `DelayTestButton` reverses its animation in `finally`, so
an RPC failure cannot leave the control permanently spinning.
Proxy delay testing follows the same failure-safe UI rule. `ProxiesAction._runDelayTests` marks its keys in
`pendingDelayTestsProvider` and releases them in `finally`, and a test the Core does not answer leaves the last
measurement in place; the delay-test bullets under Core API Safety in `.agents/rules.md` give the reasons. A group's delay
test button, the tab page's action and the list page's group header alike, spins while `delayTestingGroupsProvider`
holds the group; `delayTestPageGroup` marks it, ignores a second request for the same group, and clears it in `finally`,
so an RPC failure cannot leave the button spinning and remounting a header does not lose the state.
The dashboard's probe cards show a failure as still checking for five seconds after they see `proxied` turn true,
through `ProbeStartHold` in `lib/views/dashboard/probe_start_hold.dart`: `ServiceStatusCard` holds its check and the
exit IP beside it, and `NetworkDetection` holds the exit IP, whose tap to retry stays inert meanwhile.
`SetupAction.setRunning` flips the run time, and with it `proxied`, before the Core has brought its listeners up, so the
probe launched on the spot often fails, and the config re-apply that follows every start probes again moments later;
without the hold a card flashes a failure and then takes it back. The hold is local display state like the connecting
hold above: the cache still records the failure, the hold arms only on an observed transition, a result that is not a
failure shows at once, a stop cancels it, and a failure still standing when it ends is shown.
## Route Consistency
A probe result on the dashboard — the exit IP, a node's exit IP, a service check — either describes the route in
force or is being asked again. The full design is `docs/plans/probe-route-consistency.md`; the mechanism has three
parts.
- The Core is the source of truth for the route. `core/route.go` keeps a structural epoch (advanced by a config
apply, a mode or IPv6 switch, and any proxy provider whose version changed) and a `picks` map from each Selector,
URLTest and Fallback group to what it resolves to right now, versioned on every change. `handleChangeProxy`
re-reads the picks under `selectMu` and answers `ChangeProxyResult{changed}`, so the connection reset only runs when
the pick moved. A health check moving a URLTest pick has no mihomo hook, so the picks are polled every second while
the host watches (`watchRoute(true)`) and the listeners are up. Every probe answer carries the counters it started
under; `routeChanged` publishes the state whenever it differs from the last read.
- `routeTrackerProvider` in `lib/providers/route_state.dart` merges that with the host's side: `proxied` (running and
not suspended), a host epoch that a connectivity event moves, and `synced`, which is false until `watchRoute` has
returned a snapshot and again after a Core disconnect. Nothing is fresh while unsynced, so a cache never judges
answers against counters it made up. The tracker subscribes when the first target is watched and releases the Core
when the last one goes; an answer newer than the state means an event was evicted, and the cache asks for a resync.
It also releases the Core while the app is hidden and reads it again on return, keeping `synced`: the Core and its
counters are the same ones, so the fresh snapshot invalidates only what moved. On Android the Core drops the watch
itself when the event listener is cleared, because an engine that goes away never sends `watchRoute(false)`.
- `RoutedProbe` in `lib/providers/routed_probe.dart` is the one cache both `OutboundIpProbe` and `ServiceStatus` mix
in. A widget `watch`es the target it shows and `unwatch`es on the way out; only watched targets are probed, one
request in flight per target. An answer is stamped with the Core's counters and the host state at launch, and
`RouteState.isFresh` walks its chain against the picks, so a selection change only invalidates results that went
through the changed group while an epoch change invalidates everything. A stale watched entry is asked again at
once; an unwatched one is dropped. Nothing launches while the app is hidden: what falls due waits for it to be shown.
Failures wait for `retry`, `refresh`, or the app resuming (`markResumed`), and
a service check that timed out is a failure that still shows its status. `refresh` is the user's own request and
runs for unwatched targets too: the service sheet reads the cache and never watches its rows, because one service
check is one to four full HTTPS fetches where a delay test is a single empty request.
`ipQuality(ip)` in `lib/providers/ip_quality.dart` is keyed by the outbound IP found, so a route change reaches it as
a new IP and an unchanged IP reuses the result. A lookup races its sources the way the IP check does and takes the
first one that determines a type. A success is kept for the session with `ref.keepAlive()`; a failure is not cached,
and Riverpod's automatic retry is off because a retry after an HTTP 429 spends the same per-outbound-IP quota again.
## Icons
`lib/icons/` holds every icon the app shows. Nothing in `lib/` uses the Material icon font, and
`test/lint/material_icons_test.dart` fails on any `Icons.*` or `AnimatedIcons.*`, so every icon shares one stroke and
can animate its shape.
A `Glyph` is a list of shapes on a 24-unit grid, stroked at `Glyph.strokeWidth` with round caps and joins and
superellipse corners; a curve the primitive shapes cannot draw is a `GlyphPath`, SVG path data traced along the
stroke's center. Each shape has a role that decides its filled form, the way SF Symbols and Fluent pair an
outlined icon with its filled variant:
- `body` fills;
- `detail` is cut out of the fill, and a `solid` detail such as a dot is cut as an area;
- `line` stays a stroke, for parts that belong to no body, like a handle or a link between nodes; it thickens from
`Glyph.strokeWidth` toward 2.2 as the glyph fills, so a glyph of lines alone still carries weight in a filled button.
`GlyphPainter` paints any point between the two forms. Each body fills inward from its outline, reaching half its
shorter side, which no point of the body lies beyond, as `fill` reaches 1. Details thin away over the first half and
return as cutouts over the second, so a detail is never a stroke and a cutout at once. The glyph is painted opaque into
one layer that carries the color's alpha: a translucent color, such as a disabled one, then stays even where strokes
cross the fill, and a cutout clears only the glyph's own pixels. `GlyphIcon` shows a glyph at a fixed `fill`, taking
its size, color, and fill from `IconTheme` unless it is given its own, as `Icon` does, and mirroring it in right-to-left
text when `Glyph.matchTextDirection` is set.
`AnimatedGlyph` drives a `GlyphIcon` from its `filled` flag and pops briefly when it fills. A glyph whose shape follows
some other state, like the sidebar toggle that tracks the pane's width, is a builder in `AppGlyphs` such as
`AppGlyphs.sidebar(progress)`. A new glyph goes in `AppGlyphs` and in the list `test/icons/glyph_painter_test.dart` runs
through its checks; it is named for what it means in the app, one glyph per meaning, with SF Symbols and Fluent as the
reference for the metaphor. Tests find one with `find.byGlyph` from `test/helpers/glyph_finders.dart`.
Icons are outlined by default and filled inside a filled button: `IconButton.filled` and `.filledTonal`, `FilledButton`
in its tonal and icon forms, and floating action buttons draw their glyph with `fill: 1`, because an outline on a tinted
container reads weaker than the container it sits in. A glyph with no body only thickens its lines and still takes
`fill: 1`, so the rule holds if the glyph later gains one. `test/lint/filled_button_glyphs_test.dart` checks every
`GlyphIcon` written inside such a button; an icon that reaches the button from elsewhere, as through a helper that picks
the button variant, gets its fill by hand or from `IconThemeData.fill`. Everywhere else fill marks state only: the
selected destination, a pinned window, a lit torch. The phone's floating navigation bar is the one exception: like an iOS
tab bar it fills every destination and marks the selected one by colour alone.
Framework widgets that draw their own icon take it from the theme: the `ActionIconThemeData` in `lib/application.dart`
supplies the back and close buttons, with `AppGlyphs.backFor` picking the platform's back glyph, and a `DropdownMenu`
is given its chevrons. A framework widget added later that paints a Material icon needs the same treatment.
Empty states draw no image assets either. `NullStatus` in `lib/widgets/null_status.dart` shows the glyph for its
meaning filled on a Material 3 Expressive shape from `material_new_shapes`, with a few accent marks, and enters with
one of three choreographies picked at random: the shape spins in, morphs out of a circle, or springs in while the
accents orbit into place. The glyph takes `onPrimaryContainer`, not `primary`: the `content` scheme variant can leave
`primaryContainer` as light as `primary` in dark mode.
`CaptionIcon` is the one icon that does not follow the glyph grid: the window caption buttons keep the one pixel,
pixel-aligned stroke of Windows' 10-pixel caption glyphs on every platform.
## App Bar Actions
`CommonScaffold` keeps at most three buttons in sight in its bar, the overflow menu button included. A page hands it
what may fold as data: `primaryAction`, `iconActions`, `menuItems`, and `searchActions` while searching; custom widgets
passed through `actions` never fold. Past three, two buttons stay beside the overflow menu: the search button (the clear
button while searching) when there is one, then foldable actions, primary action first unless `foldPrimaryAction` puts
the icon actions ahead of it;
everything else goes into one overflow menu, primary action first, and a folded action that is loading shows as a
disabled item. The bar reads as one button group of two beside a single button: the overflow menu button when
something folds, otherwise the last action, so the proxies list shows a search and delay test group beside the menu. `primaryAction` counts only where the bar holds it, in a bottom sheet and on a phone's home
page, whose foot the navigation dock takes; elsewhere it is the page's floating action button. No page adds a
floating action button of its own: a secondary action such as selecting every app goes into `menuItems`, and saving
an edit page is a check among its `iconActions`. Actions on a multi-selection, such as delete and select all, go into
`selectionActions` instead: the bar keeps them in sight as one button group that takes a single slot beside the search
or clear button, so they never fold.
## Settings Rows
`lib/widgets/config_item.dart` holds the shared settings-row vocabulary: `ConfigToggleItem`, `ConfigOptionsItem`,
`ConfigTextItem`, and `ConfigListInputItem`. Each takes a `selector` (a `ProviderListenable`, normally
`ConfigTextItem`, and `ConfigListEditItem`. Each takes a `selector` (a `ProviderListenable`, normally
`someProvider.select(...)`) and an `onChanged(ref, value)` writer, and watches its own selector so changing one setting
rebuilds one row instead of the whole section. Titles and subtitles are `ConfigLabel` callbacks that receive
`AppLocalizations`, which keeps literal labels such as `IPv6` and localized labels in the same shape.
Build settings screens from these directly, or from a file-local helper that binds one provider once — see `_dnsToggle`
in `lib/views/config/dns.dart` and `_appSettingToggle` in `lib/views/application_setting.dart`. Declare a named
Build settings screens from these directly, or from a file-local helper that binds one provider once — see `_networkToggle`
in `lib/views/config/network.dart` and `_appSettingToggle` in `lib/views/config/general.dart`. Declare a named
`ConsumerWidget` only when a row is genuinely reused across screens, as `lib/views/config/network.dart` rows are by
`lib/views/dashboard/widgets/quick_options.dart`. Rows with bespoke behaviour — a custom dialog, a derived value, or a
second provider write — stay hand-written rather than growing extra parameters on the shared items.
@@ -215,6 +379,8 @@ Provider files in `lib/providers/`:
- `state/overwrite.dart`: custom overwrite validity and the staged group/rule notifiers.
- `action.dart`: business logic notifiers, setup, backup, core lifecycle, proxy selection.
- `core.dart`: `coreHandlerProvider`, the container-scoped handle on `CoreController`.
- `route_state.dart` and `routed_probe.dart`: the route tracker and the probe cache; see Route Consistency above.
- `outbound_ip.dart` and `service_status.dart`: the dashboard probes built on them.
- `database.dart`: Drift database provider wrappers.
### Reaching Singletons
@@ -244,7 +410,7 @@ itself live in one. Code without a `Ref` reaches providers through
`lib/providers/actions/system.dart` — all singletons or platform callbacks with no
`Ref` in scope.
`lib/models/profile.dart` no longer reaches Core. `Profile.saveFile` and
`lib/models/profile.dart` does not reach Core. `Profile.saveFile` and
`Profile.update` take a `ValidateConfig` callback, and every caller passes
`(path) => _core.validateConfig(path)`, keeping the Core handle lazy so a profile
path that never validates never resolves the controller.
@@ -269,15 +435,15 @@ generated into `lib/providers/generated/`.
The root navigator key lives in `lib/common/navigator.dart` as `rootNavigatorKey`;
`globalState.navigatorKey` is a getter onto it. `lib/common/dialog.dart` reaches
the key directly, so the dialog helpers no longer import `lib/state.dart`.
the key directly and does not import `lib/state.dart`.
### Platform Layering
`lib/common/common.dart` deliberately does not export `tray.dart`, `window.dart`,
`launch.dart`, `system_dns.dart`, or `permission.dart`. Those five modules import
`tray_manager`, `window_manager`, `launch_at_startup`, and `screen_retriever`;
exporting them put those packages in the compile graph of all 132 files that
import the barrel for a string helper. Import the specific module instead.
`tray`, `window`, `launch_at_startup`, and `screen_retriever`;
exporting them would put those packages in the compile graph of every file that
imports the barrel for a string helper. Import the specific module instead.
`test/lint/platform_layering_test.dart` enforces four rules. Three are local: the
barrel never re-exports one of those five modules, nothing under `lib/common`,
@@ -289,16 +455,16 @@ closure and fails if *any* file in it imports one of those packages. That one is
the real invariant — the local rules only stop the shortest path, and every leak
found so far arrived through a longer one.
Four consequences are already in the tree:
The shapes that keep the closure small:
- `System.back` and `System.exit` no longer touch `window`; the window half of
- `System.back` and `System.exit` do not touch `window`; the window half of
both lives in `SystemAction`.
- `KeyboardModifier.toHotKeyModifier()` moved from `lib/enum/enum.dart` to
`lib/manager/hotkey_manager.dart`, its only consumer.
- Startup orchestration moved off `GlobalState` into `lib/bootstrap.dart`.
- `KeyboardModifier.toHotKeyModifier()` lives in
`lib/manager/hotkey_manager.dart`, its only consumer, not in `lib/enum/enum.dart`.
- Startup orchestration lives in `lib/bootstrap.dart`, not on `GlobalState`.
`common/num.dart`, `common/print.dart` and `common/request.dart` import
`state.dart` for `theme`, `container` and `packageInfo`/`ua`, so anything
`GlobalState` reaches lands in the barrel's closure; the ambient state it now
`GlobalState` reaches lands in the barrel's closure; the ambient state it
holds reaches nothing platform-specific.
- `SystemAction` talks to `WindowPort` and `TrayPort` from
`lib/common/app_ports.dart` instead of importing `common/window.dart` and
@@ -306,36 +472,27 @@ Four consequences are already in the tree:
the real implementations; both stay null in tests, where every call through
them is a no-op. A test that needs the real tray assigns `trayPort` itself, as
`test/common/tray_menu_test.dart` does.
- `lib/providers/providers.dart` re-exports `action.dart`, so `lib/common` and
`providers/app.dart` import `providers/state.dart` and `providers/config.dart`
directly; the barrel would reach the whole action layer.
- A data type in a lower layer never holds the widget that renders it, because
that drags the whole view tree into the barrel's closure. The route table is
`lib/views/navigation.dart` implementing `NavigationPort`, which
`providers/state/navigation.dart` reads through and `bootstrap.init` binds;
unbound it yields no items, so a test that renders navigation assigns
`navigationPort` itself, as `test/pages/home_test.dart` does. `DashboardWidget`
is plain data, since it is persisted in the app settings; the widget mapping
lives in `lib/views/dashboard/widget_registry.dart`, the reverse lookup relying
on the branches returning canonical consts.
Narrow the barrel imports too: `lib/providers/providers.dart` re-exports
`action.dart`, so importing the providers barrel from `lib/common` or from
`providers/app.dart` reaches the whole action layer. Those three now import
`providers/state.dart` and `providers/config.dart` directly.
The same shape appeared twice more, without a platform package involved: a data
type in a lower layer holding the widget that renders it, which drags the whole
view tree into the barrel's closure.
- `lib/common/navigation.dart` was a route table building view widgets. It is
now `lib/views/navigation.dart` implementing `NavigationPort`, which
`providers/state/navigation.dart` reads through and `bootstrap.init` binds.
Unbound it yields no items, so a test that renders navigation assigns
`navigationPort` itself, as `test/pages/home_test.dart` does.
- `DashboardWidget` carried a `GridItem` per value, so `lib/enum/enum.dart`
imported the dashboard cards — and `lib/widgets/widgets.dart` with them. The
enum is persisted in the app settings, so it is back to plain data; the
mapping lives in `lib/views/dashboard/widget_registry.dart`, the reverse
lookup relying on the branches returning canonical consts.
Together those took the closure from 258 files to 187, with nothing under
`lib/views` left in it. `test/lint/platform_layering_test.dart` pins that
directly: the barrel's closure must contain no `lib/views` file. Data the
provider layer needs from the UI layer goes through a port in
`lib/common/app_ports.dart` rather than an import in the other direction.
`test/lint/platform_layering_test.dart` also pins that the barrel's closure
contains no `lib/views` file. Data the provider layer needs from the UI layer
goes through a port in `lib/common/app_ports.dart` rather than an import in the
other direction.
### High-Frequency Buffers
`logsProvider`, `requestsProvider` and `trafficsProvider` hold a `FixedList`
`logsProvider`, `requestsProvider`, `dnsQueriesProvider` and `trafficsProvider` hold a `FixedList`
(`lib/common/fixed.dart`), which trades a normal copy-on-write for a shared
buffer tagged with a generation counter:
@@ -354,9 +511,35 @@ buffer tagged with a generation counter:
`add`/`clear` mutate in place without advancing the generation; use them only on
a buffer you own outright (seeding, resets, tests), never on published state.
### Background Work
A proxy client runs for days with nobody looking at it, so work that only feeds the UI stops when the UI cannot be
seen. There are three levels of gating:
- `appVisibleProvider` is false while the desktop window is hidden or the Android activity is in the background
(`hidden`, `paused`, `detached`). `AppStateManager` writes it. The one-second run-time and traffic ticker in
`SetupAction` follows it. The macOS menu bar title is the exception: it shows the traffic while the window is
hidden. `RouteTracker` and `RoutedProbe` follow it too, as described under Route Consistency.
- `ActivePollingMixin` is stricter for per-widget polls such as connections and memory. It polls only while the app is
`resumed` and its home page is the current one.
- `_NavigationPage` puts every page except the current one under a disabled `TickerMode`. Home pages are kept alive in
the `PageView`, and Flutter does not mute tickers on a kept-alive page off screen. A spinner left on one would
otherwise keep scheduling frames.
A provider's own `ref.listen` delivers nothing while no one listens to that provider. That is the usual state of an
action notifier, which is only ever read. A derived provider it listens to is never recomputed, and even a `ref.read`
of it returns a stale value. So an action that has to follow a signal listens to the keep-alive provider that holds
it, as `SetupAction` does with `appVisibleProvider`, not to a provider derived from that.
On Android, `SuspendModule` suspends the Core while the screen is off and the device is in Doze. Health checks that
run in Doze all fail, so after a suspension the Core re-probes every provider. That re-probe waits for the screen to
come on. A Doze maintenance window resumes the Core too, but probing there would spend the window's radio time on
results nobody sees.
## Database
The app uses Drift/SQLite in `lib/database/`. Current schema version is 2.
The app uses Drift/SQLite in `lib/database/`. `schemaVersion` in `lib/database/database.dart` is the migration
counter; every schema change bumps it and adds the matching migration step.
Tables:
@@ -365,10 +548,19 @@ Tables:
- `Rules`
- `ProfileRuleLinks` (`profile_rule_mapping`)
- `ProxyGroups`
- `CustomProxies` (`custom_proxies`)
- `IconRecords` (`icon_records`)
- `ClashProviders` (`clash_providers`)
Rule scenes distinguish global added rules, profile added rules, profile custom rules, and disabled links. Rule and proxy-group ordering use fractional indexing.
App-level providers (`ClashProviders`, plus every profile offered as a proxy provider) and a custom overwrite's own
proxies (`CustomProxies`) are soft-disabled through `feature.customProviders` and `feature.customProxies`
(`lib/common/feature.dart`), switched on by `--dart-define=FEATURE_CUSTOM_PROVIDERS=true` and
`FEATURE_CUSTOM_PROXIES=true`. While off, their entries are hidden, `appProviderNames` and `appProviderLabels` are
empty, and `setupState` loads neither, so stored rows stay in the database but never reach a profile. Backups still
carry both tables.
Generated Drift output lives in `lib/database/generated/database.g.dart`. After schema changes, run code generation and add or update focused database tests under `test/database/` when converter or migration behavior changes.
## Manager Stack
@@ -376,7 +568,7 @@ Generated Drift output lives in `lib/database/generated/database.g.dart`. After
Managers are nested `InheritedWidget`/`StatefulWidget` components built by `buildManagerStack()` in `lib/application.dart`:
```text
AppEnvManager > StatusManager > ThemeManager
AppEnvManager > LocaleManager > StatusManager > ThemeManager
> [Desktop: WindowManager > TrayManager > HotKeyManager > ProxyManager]
[Mobile: AndroidManager > TileManager]
> AppStateManager > CoreManager > ConnectivityManager
@@ -401,7 +593,7 @@ updating both this diagram and that test.
## Core Controller and Actions
`lib/core/controller.dart` (`CoreController`) is a singleton facade over `CoreHandlerInterface`. Public methods delegate to the platform-specific interface, either Android FFI or desktop socket. It has an `@visibleForTesting` constructor and `resetInstance()` for test injection.
`lib/core/controller.dart` (`CoreController`) is a singleton facade over `CoreHandlerInterface`. Public methods delegate to the platform-specific interface, either the Android service channel (`CoreLib`) or the desktop socket. It has an `@visibleForTesting` constructor and `resetInstance()` for test injection.
`lib/providers/action.dart` is the public library entry point for action
providers. The Riverpod notifier implementations are split by responsibility
@@ -410,7 +602,10 @@ directives, so consumers continue to import the same public API:
- `CommonAction`: update check and common UI operations.
- `SetupAction`: config setup and TUN management.
- `BackupAction`: backup/restore with WebDAV sync.
- `BackupAction`: backup/restore orchestration over a per-run temp work directory; the view supplies where an archive
goes (local save, WebDAV upload) or comes from. The archive format lives in `lib/common/backup.dart`, which stages a
restore without touching the data directory; files, database rows and settings are applied only after the whole
archive has been read and validated.
- `CoreAction`: core lifecycle, initialization, coalesced restart, and post-restart profile/running-state application.
- `SystemAction`: system integration, tray, coordinated resource cleanup, terminal Core close, exit, and brightness.
- `StoreAction`: profile storage operations.
@@ -430,28 +625,50 @@ invariant, not a policy, and it must stay inside the notifier where no warm-up o
only the policy half: the periodic sweep that discards a key stuck past `updatingStaleTimeout`. Do not move the
timeout back into `UpdatingKeys`, and do not widen the disconnect reset to every scope.
## Platform Managers
## Safe Mode
Desktop:
`--dart-define=SAFE_MODE=true` builds a FlClash that never changes the host network, so a development build can run
next to an installed one. It is a compile-time constant with two faces: `safeModeBuild` in `lib/common/constant.dart`
for the process-wide handles that exist before the provider container, and `safeModeProvider` in
`lib/providers/app.dart` for everything reached through Riverpod, which is also what tests override. Settings are never
rewritten; each owner reads the flag where it would otherwise act:
- `WindowManager`
- `TrayManager`
- `HotKeyManager`
- `ProxyManager`
- Core config: `SetupAction._effectivePatchConfig` is the one place the profile path and `updateConfig` derive what
the Core receives from the user's `PatchClashConfig`. In safe mode it reports `tun.enable: false` and
`ExternalControllerStatus.close`, so the generated profile and every `updateConfig` carry no TUN and an empty
`external-controller` (the Core's `route.ReCreateServer` binds no RESTful API port) while the settings keep the
user's values and the installed app keeps `127.0.0.1:9090`. `SetupAction.requestAdmin` skips authorization.
- System clock: `_makeRealProfileTask` sets `write-to-system: false` on whatever `ntp` section the generated profile
ends up with, so neither the user's override nor the profile's own NTP config can move the host's time.
- System proxy: the `proxy` plugin handle in `lib/common/proxy.dart` is `null`, so `ProxyManager` and exit cleanup
never write the OS setting, not even the `stopProxy` that would clear what the installed app configured.
- System DNS: `systemDnsCoordinator` is `null` and `shouldPatchSystemDnsProvider` is `false`, so neither the apply nor
the restore path runs. The restore path matters because a profile or release build shares the preferences of the
installed app (only a macOS or Android debug build has its own bundle id) and would restore from its record.
- Login item: the `autoLaunch` handle in `lib/common/launch.dart` is `null`, so neither the boot sync nor the setting
listener registers the build's executable or removes the installed app's entry, which on Windows is the same
`FlClash` value in the user's Run key.
- Privileged Core: `CoreService` resolves the launcher with `hasHelper: false`, which is the direct child-process path
on every desktop platform; the Helper keeps managing the installed app's Core.
- Core listeners: `SetupAction._setCoreRunning` and the `AppStateManager` suspend listener never call `startListener` or
`stopListener`, so the Core's `isRunning` stays false and `updateListeners` binds nothing, while `isStartProvider`
and `applyProfile` behave as usual. The installed app keeps the mixed, DNS and other loopback ports.
- Android: `sharedStateProvider` hands the service `VpnOptions.enable: false` and `systemProxy: false`, so the
foreground service runs without a `VpnService`.
- Wi-Fi SSID: `ConnectivityManager` never reads the SSID and `Permissions.checkLocationPermissions` neither checks
nor requests location authorization, so `currentSSIDProvider` stays `null` and the excluded-network suspend never
trips. The macOS `wifi_ssid` plugin builds its `CLLocationManager` and `CWWiFiClient` lazily, so a build that
never asks never reaches locationd or wifid.
- Global hotkeys: `HotKeyManager` neither subscribes to hotkey events nor registers the configured shortcuts with the
OS; the in-window `Shortcuts` (close window, escape) stay.
- URL schemes and launcher shortcuts: `Window.init` skips the Windows registry and Linux desktop-entry registration
of the `clash` schemes, and Android never calls `initShortcuts`.
- Presentation: `AppEnvManager` shows a `SAFE MODE` banner ahead of the channel banner, and `TrayState.safeMode` gives
the tray the `status_4` bug icon, a suffixed tooltip and a menu of only show and exit. `trayStateProvider` and
`trayTitleStateProvider` report `showTrayTitle: false`, so the macOS menu bar carries no traffic title.
Mobile:
- `AndroidManager`
- `TileManager`
- `VpnManager`
Shared:
- `ConnectivityManager`
- `CoreManager`
- `AppStateManager`
- `StatusManager`
- `ThemeManager`
A new host integration, anything that changes the OS's routes, proxy, DNS, login items, privileges or VPN state, or
that asks the OS for a permission, a global shortcut or a registration, must consult the flag at its owner in the same way and get a test that overrides `safeModeProvider`.
## Build System
@@ -583,14 +800,95 @@ artifacts under `src/main` are whatever the last hook run left behind, which is
detect. Verify a change here with `./gradlew ":core:buildCMakeDebug[arm64-v8a]" :app:compileFlutterBuildDebug
--dry-run`: the Flutter task must be listed first even though the CMake task was requested first.
#### Android Crash Symbols
Crashlytics NDK symbolicates a native crash only from libraries that still carry their symbols, and AGP strips every
`.so` it packages, so the Android build keeps symbols until `:app`'s own strip step and the APK does not grow:
- The hook drops `-s` and `-w` from `go_ldflags` for the `c-shared` Android targets only; the desktop Cores stay
stripped because nothing consumes their symbols.
- `:core` compiles with `-g`, does not `--strip-all`, and sets `packaging.jniLibs.keepDebugSymbols` so its own strip
task leaves `libclash.so` and `libcore.so` intact on the way into `:app`'s merged native libs, which is where the
Crashlytics plugin reads them.
- `:app` turns on `nativeSymbolUploadEnabled` and finalizes `assembleRelease` and `bundleRelease` with
`uploadCrashlyticsSymbolFileRelease` only when release signing is configured, the same condition that marks a real
release build; the checked-in `google-services.json` is a placeholder, so a local upload is noise at best, and CI
supplies both files together. The Crashlytics plugin wires the R8 mapping upload itself but leaves the native symbol
upload to the caller, so nothing else triggers it. `generateCrashlyticsSymbolFileRelease` runs `dump_syms` over the
Go Core for every ABI, which is the other reason it stays off for unsigned builds.
## Local Plugins
- `setup`: build-time harness for Go core artifacts and the Rust helper, driven by a Dart build hook; no runtime Dart API.
- `proxy`: system proxy configuration.
- `rust_api`: runtime Flutter Rust Bridge FFI package built through Native Assets. See below.
- `tray`: system tray for Linux, macOS and Windows. Written for FlClash; replaced the `tray_manager` fork.
- `window`: desktop window control for Linux, macOS and Windows. Written for FlClash; replaced the
`window_manager` fork. See below.
- `wifi_ssid`: Wi-Fi SSID detection.
- `flutter_distributor`: app packaging/distribution.
- `code_forge`: the text editor behind `EditorPage`, derived from upstream `code_forge`. See below.
## code_forge Plugin
`CodeForgeController` is the document: text lives in the Rust `RopeBridge`, and the controller's methods are split by
concern into `lib/code_forge/controller/` extensions. `CodeForge` builds `_CodeFieldRenderer`, whose painting, layout,
folding, caret and gutter code sit in `lib/code_forge/code_area/` part files.
- Rust counts offsets in Unicode scalars and Flutter in UTF-16 code units. Convert at the boundary with
`text_offsets.dart`; a mixed-up offset only shows up with astral characters such as emoji, so tests use them.
- The renderer follows edits through the controller's `lineEdit` (first line, lines removed, lines inserted) and never
reads the whole text per edit; a whole-text `text =` assignment resets the renderer instead.
- FlClash supplies the suggestion popup, context menu and scrollbar; the package has no built-in versions of them.
- Tests live in `test/plugins/code_forge/` at the repository root and need the `rust_api` library.
## window Plugin
`plugins/window` exposes `desktopWindow` (`DesktopWindow`) on the `window` method channel; `plugins/window/README.md`
is the channel contract (methods, arguments, events, effects, runner hooks) and every platform implements exactly that
table. `lib/common/window.dart` and `lib/manager/window_manager.dart` are the only application callers.
- Each platform keeps one style record (title bar style and button visibility everywhere, rounded corners on
Windows, effect, tint and brightness on Windows and macOS) and reapplies it whole from a single `apply` routine, so DWM margins
on Windows and `isOpaque`/effect views on macOS have one writer. Add a visual setting to that record, never a second
code path.
- Method dispatch is a table on every platform, and every handler validates its arguments; a bad call answers with a
`bad_args` `PlatformException`, a capability the platform lacks with `unsupported`. Coordinates cross the channel in
logical pixels; Windows converts with `GetDpiForWindow`.
- Position and size changes surface as one `geometry-changed` event that each platform coalesces natively (150 ms
after the last `WM_WINDOWPOSCHANGED`, did-move/did-resize, or configure-event); `WindowManager` only debounces the
capture on top of it.
- Windows: with `titleBarStyle: hidden`, `WM_NCCALCSIZE` takes the frame insets from `AdjustWindowRectExForDpi` and
keeps the client rect at the top edge (plus the frame height when maximized, so the taskbar stays uncovered; plus
1 px on Windows 11, which draws the top border only into a non-client strip; none on Windows 10, where DWM draws
the whole caption over any strip shorter than one). Windows 10 gets its top border the Windows Terminal way
instead: the DWM frame is extended over the caption height, the plugin places the Flutter view 1 px lower and
paints that row black, which DWM treats as alpha 0, and hit-tests it as `HTTOP`; see `plugins/window/README.md`.
The plugin therefore owns the view placement on `WM_SIZE`. Fullscreen is emulated by the
plugin, which raises `enter-full-screen` and
`leave-full-screen` itself and mutes `WM_SIZE` while it is on. The find-running-window IPC
(`WindowPluginFindRunningWindow`/`WindowPluginActivateWindow`) registers a window message from the executable path
and whitelists it through UIPI so a non-elevated launcher reaches an elevated instance.
- Linux: GTK drops the placement of an unmapped window, so `hide` saves the geometry and the `map-event` handler
applies it, then corrects the placement for 150 ms while the window manager settles (it may place the window
after the map handler ran). `isPositionSupported` is X11-only; Wayland owns placement and re-shown windows land where the
compositor puts them. Linux supports no effect: GTK 3 has no desktop-neutral blur protocol, so `isEffectSupported`
is true only for `none` and the runner keeps the default visual.
- macOS: `WindowPlugin.instance` is reached from `AppDelegate` for `applicationShouldTerminate` (emits
`should-terminate`) and `applicationShouldHandleReopen` (emits `activate`); `MainFlutterWindow` calls
`hiddenWindowAtLaunch()` so the first `order` keeps the window hidden until Dart shows it.
- Window effects (`transparent`, `blur`, `acrylic`, `mica`) are capability-gated by `isEffectSupported`; the Flutter
content must paint a translucent background for them to show. `ThemeProps.sidebarBlur` (on by default) drives
them: `Window.setBlur` picks `blur` on macOS and `acrylic` (or `blur`) on Windows and caches only a successful
probe, so a platform that answered unsupported is asked again the next time blur is turned on. `WindowManager` reapplies it
whenever the setting or the app brightness changes and records the outcome in `windowBlurProvider`, and only the
sidebar and the caption strip paint translucently while it is on. On macOS the sidebar paints fully transparent
and the effect view follows the window's active state, which is the flutter_acrylic `TransparentMacOSSidebar`
look; Windows keeps a 50% tint because the accent effects blur too little for the rail to stay readable.
- Sidebar blur is soft-disabled: `feature.sidebarBlur` (`lib/common/feature.dart`) is off unless the build passes
`--dart-define=FEATURE_SIDEBAR_BLUR=true`. While it is off the theme page hides the toggle and
`windowBlurRequestProvider` asks for no effect whatever `ThemeProps.sidebarBlur` holds. `WindowManager` still sends
that request at startup: the macOS plugin installs its effect view in `ensureInitialized` and leaves it active until
the first `setEffect`, so the `none` request is what makes the window opaque and parks the effect view.
## rust_api Crate Layout
@@ -601,6 +899,12 @@ Cargo and registers `librust_api` as a code asset that Flutter bundles and signs
the hook refuses `stable` and a target missing from that list. `rustup show` installs the pinned toolchain and those
targets on first use.
`flutter_rust_bridge_codegen generate` (see `.agents/commands.md`) writes `lib/src/rust/frb_generated.dart`,
`frb_generated.io.dart`, `frb_generated.web.dart`, one `lib/src/rust/api/<module>.dart` per Rust API module, and
`rust/src/frb_generated.rs`; none of them is edited by hand. A new Rust function is a public function in `rust/src/api/`,
plus `pub mod <name>;` in `api/mod.rs` and an export from `lib/rust_api.dart` when it starts a new module. `RustLib` is
the singleton entry point: `RustLib.init()` runs before any call and `RustLib.dispose()` on teardown.
`plugins/rust_api/rust/src/` separates the bridge boundary from the code behind it:
- `api/` is the only input flutter_rust_bridge parses (`rust_input: crate::api`). Every function there is a thin
@@ -609,6 +913,10 @@ targets on first use.
bounded send queue), `platform` (socket cleanup, Windows peer credentials and the non-blocking pipe reader), and
`server` (lifecycle, accept loop, and the `RUNNING`/`STATE` globals).
- `script/` runs profile override scripts on QuickJS through `rquickjs`.
- `editor/` backs `plugins/code_forge`: a `ropey` buffer with its selection, fold ranges and bracket matching,
indent guides for the viewport, and completion words. `api/editor.rs` exposes the buffer as the opaque
`RopeBridge`. Only the editor imports this module, through `lib/editor.dart`, so `lib/rust_api.dart` keeps its
generic names such as `SelectionState` out of app code.
- `hotkey/` registers desktop global shortcuts through `global-hotkey`: `keys` maps Flutter USB HID usages to key
codes, `owner` runs every registration on the thread the platform binds it to (a dedicated message-loop thread on
Windows, the main dispatch queue on macOS, in place on Linux), and `service` owns the registry and forwards presses
@@ -619,7 +927,8 @@ What a platform does not use, it does not compile. `interprocess` and `global-ho
Android loads the Core in-process and has no global shortcuts. Adding a capability follows the same shape: implement it in its own module, gate the
dependency by target, and keep the `api/` entry point unconditional so one set of bindings still serves every platform.
`RustLib.init()` runs on every platform now, not only desktop — the script engine is shared.
`RustLib.init()` runs on every platform in `main`, because the script engine and the editor are shared; the editor
shows its unavailable state when `RustLib` is not initialized rather than loading the library itself.
## Profile Script Engine
+47 -11
View File
@@ -49,13 +49,17 @@ hooks:
build_assets: true # false does the same for the Rust library
```
CI flips both to `false` with `yq` before `dart run tool/changelog.dart` and
`flutter test`, and restores the file afterwards; no test loads either library.
CI flips `setup` to `false` with `yq` before `dart run tool/changelog.dart` and
`flutter test`, and restores the file afterwards; no test loads the Go core. `rust_api`
stays on there because the editor tests in `test/plugins/code_forge/` load the Rust
library the hook builds; the release-notes job, which runs no tests, flips both.
Never commit `false`: a build with it set stages whatever `libclash/` already
holds and bundles no Rust library, which is why `setup.dart` refuses to package
while it is set. Locally, `false` is worth setting for a Dart-only test loop,
but the hook cache keys on the user-define, so the first build afterwards runs
the hooks again.
the hooks again. The editor tests still need the Rust library: keep `rust_api`
on, or point `FRB_DART_LOAD_EXTERNAL_LIBRARY_NATIVE_LIB_DIR` at a directory that
holds a `rust_api` library built from the same sources.
## Flutter Development
@@ -69,6 +73,23 @@ flutter test
Use `flutter test`, not `dart test`, because models pull in Flutter types.
A development build takes over the host exactly like a release: it creates the TUN device, sets the system proxy, on
macOS rewrites the system DNS, registers itself as the login item, and on Windows and Linux starts its Core through the
shared privileged Helper, which stops the Core an installed FlClash is running. Only the data directory is kept apart,
and only for some builds: a macOS debug build is `com.follow.clash.debug` and an Android debug build `com.follow.clash.dev`,
while profile and release builds, and every build on Windows and Linux, share the directory and preferences of the
installed app. Safe mode keeps all of the host changes off and never opens the Core's listeners or external
controller, so it does not compete with the installed app for the mixed, DNS, RESTful API and other loopback ports:
```bash
flutter run --dart-define-from-file=env.json --dart-define=SAFE_MODE=true
```
`.run/main_safe_mode.run.xml` is the same launch for IntelliJ, and `Flutter App (Safe Mode)` in `.zed/debug.json` (debugger) or `flutter run (Safe Mode)` in `.zed/tasks.json`
(plain run) for Zed. The build is bannered `SAFE MODE`, the tray shows the
`status_4` bug icon with a menu of only show and exit and no traffic title, and the settings keep their values, so
nothing has to be flipped back before a normal run. See the safe mode section of `architecture.md` for the owners that consult the flag.
## Code Generation
Run code generation after modifying models, providers, or database schema:
@@ -84,6 +105,14 @@ Code generation covers:
- Models through `freezed` and `json_serializable`.
- Database tables through `drift_dev`.
The Dart-Rust bindings in `plugins/rust_api` are generated by `flutter_rust_bridge_codegen` from
`plugins/rust_api/flutter_rust_bridge.yaml`; rerun it from that directory after changing anything under
`rust/src/api/`:
```bash
cd plugins/rust_api && flutter_rust_bridge_codegen generate
```
Generated output paths, configured in `build.yaml`:
- `lib/models/generated/*.g.dart`, `*.freezed.dart`.
@@ -123,6 +152,9 @@ flutter test plugins/proxy/test/proxy_test.dart
also what CI runs; `tool/check_plugins.sh` does not descend into it. It must stay out of `flutter test`, because a test
run of `plugins/setup` would execute the build hook itself.
Native plugin code under `plugins/*/{linux,macos,windows}` is compiled by the CI `desktop` job, which runs
`flutter build <platform> --debug` on every push; `tool/check_plugins.sh` only analyzes and tests the Dart side.
Root `flutter test` only discovers the root package's `test/` directory by default. Include bundled plugin Dart tests by passing paths explicitly, or run `flutter test` from that plugin package directory, or run `bash tool/check_plugins.sh` to analyze and test every plugin package the way CI does. Native plugin tests under platform folders are not run by `flutter test`.
For the current Core/service architecture, useful focused checks are:
@@ -167,12 +199,14 @@ cargo test --manifest-path services/helper/Cargo.toml --features windows-service
```
The last command requires Windows for meaningful service coverage. Native Android lifecycle edits should at minimum
compile the modules they touch; use JDK 17 in this checkout:
compile the modules they touch, with the JDK 17 that CI installs. On macOS the Homebrew `openjdk@17` keg is not
registered with `java_home`, so export it by prefix:
```bash
cd android
JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradlew :service:compileDebugKotlin
JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradlew :app:compileDebugKotlin
export JAVA_HOME="$(brew --prefix openjdk@17)/libexec/openjdk.jdk/Contents/Home" # elsewhere: any JDK 17
./gradlew :service:compileDebugKotlin
./gradlew :app:compileDebugKotlin
```
Always-on VPN entry, system VPN revoke, actual permission UI, and rapid device start/stop still require Android device or
@@ -240,8 +274,9 @@ while `v<pubspec version>` is still tagged it refuses to collect anything and th
## Verify
Every branch push runs the `dart` job, which performs these root-package checks
in order:
In the public repository every branch push runs the `dart` job; in the private
`origin` every check job runs only when started by hand (`workflow_dispatch`).
The `dart` job performs these root-package checks in order:
```bash
bash tool/check_commit_msg_test.sh
@@ -254,7 +289,7 @@ flutter test --reporter expanded --coverage
dart run tool/check_coverage.dart coverage/lcov.info 75
```
Run `flutter analyze` locally before committing when practical.
Run `flutter analyze` before committing; CI fails on anything it reports.
Release builds run only for `v*` tag pushes; pull requests trigger nothing.
Root analysis excludes `plugins/**`, and root tests do not discover nested
@@ -263,8 +298,9 @@ directories: `plugins` (local Flutter packages and the setup build tool), `go`
(the Core wrapper, plus an NDK-backed vet of the Android files), `android`
(JVM unit tests for `:common`, `:service` and `:app`, with the Flutter compile
tasks excluded so no native build hook runs), `rust` (both crates), and a
Windows runner for the helper's `windows-service` feature. Release builds start
once all of them pass.
Windows runner for the helper's `windows-service` feature. A `desktop` job also
compiles a debug build per desktop platform. Release builds run only in the
public repository and start once every job except `desktop` passes.
`bash tool/check_plugins.sh` is that plugin gate, and CI runs the same script.
It discovers every `plugins/*/pubspec.yaml`, analyzes each package, and runs
+71
View File
@@ -0,0 +1,71 @@
# Flutter Upgrade Checklist
Read this before changing `FLUTTER_VERSION` in `.github/workflows/build.yaml` or `.fvmrc`. Each entry is a
workaround tied to a Flutter bug: what it does, where it lives, and what has to be true before it can go. Remove an
entry together with its workaround; add one whenever a new workaround is pinned to the SDK.
## Stable cadence
Flutter ships a stable release about once a quarter, three minor versions apart (3.41 in February, 3.44 in May, 3.47
in August 2026). The two minors in between (3.48, 3.49) are beta only, so a fix that lands on `main` reaches stable
in the next multiple, 3.50, expected around mid-November 2026. Hotfix releases (3.47.x) carry only cherry-picks
listed in the SDK's `CHANGELOG.md`.
## Impeller disabled on Intel macOS release builds
- **Bug:** [flutter/flutter#191538](https://github.com/flutter/flutter/issues/191538). From 3.47, when Impeller
became the default desktop renderer, Intel (x86_64) Macs flicker across the whole window while scrolling or
animating, and text can render garbled. Apple Silicon is not affected.
- **Fix upstream:** landed on `main` before 2026-08-25 and verified on `3.48.0-1.0.pre-404`; it is not in any
3.47.x hotfix (checked through 3.47.5).
- **Workaround:** the release job for `macos-15-intel` (`arch: amd64`) runs
`plutil -replace FLTEnableImpeller -bool NO macos/Runner/Info.plist` before `dart setup.dart`, so only the Intel
DMG falls back to Skia. The checked-in `Info.plist` does not set the key, so local builds and the arm64 DMG keep
Impeller. The macOS embedder reads the key at launch in `FlutterDartProject.mm` (`enableImpeller`), release
builds included.
- **Side effect:** Skia on Intel renders blur, shadows and text slightly differently; judge Intel-only visual reports
against Skia, not Impeller.
- **Remove when:** `FLUTTER_VERSION` moves to a stable that contains the fix (3.50 or later, or a 3.47.x whose
changelog lists #191538). Before dropping the step, build the Intel DMG without it and scroll the proxies and
dashboard pages on an Intel Mac with its integrated GPU driving the built-in display; an external display can
switch to the discrete GPU and hide the flicker.
## Windows view visibility forwarded by the runner
- **Bug:** the Windows embedder's `WindowsLifecycleManager` counts the Flutter view as a window of its own and keeps
it visible until the view is sized to zero. Hiding the top-level window never resizes the view, and the `window`
plugin keeps it sized while minimized, so the app was never told `hidden`: animations kept rendering in the tray
and `appVisibleProvider` stayed true.
- **Workaround:** `WindowPluginForwardVisibility` in the `window` plugin's
`windows/include/window/window_plugin_runner.h` hands the top-level window's `WM_SHOWWINDOW` and `WM_SIZE` to
`ProcessExternalWindowMessage` for the view's HWND, so the view shows and hides with its window.
`FlutterWindow::MessageHandler` in `windows/runner/flutter_window.cpp` calls it before `HandleTopLevelWindowProc`;
the Flutter runner template has no such call, so keep it when regenerating the runner.
- **Remove when:** the engine hides the view together with its top-level window. Check with the proxy running and
the window focused: hiding it to the tray and minimizing it must each log `AppLifecycleState.hidden`.
## macOS activation resumes a hidden window
- **Bug:** `handleWillBecomeActive` in the macOS engine's `FlutterEngine.mm` resumes when any window in
`NSApp.windows` is visible, and the tray's `NSStatusBarWindow` always is. Activating the app while its window was
minimized or hidden, such as Cmd-Tab back to a minimized window, reported `resumed`, and the next deactivation
`inactive`, so frames ran until the window was shown again.
- **Workaround:** the `window` plugin observes will-become-active and will-resign-active and, once the engine has
answered, sends `AppLifecycleState.hidden` on `flutter/lifecycle` while its window is not visible
(`applicationActivityChanged` in `WindowPlugin.swift`).
- **Remove when:** the engine ignores status item windows on activation. Check without the workaround: minimize the
window, switch to another app, then Cmd-Tab back without restoring it; the log must stay at
`AppLifecycleState.hidden`.
## Linux minimize on Wayland reads as inactive
- **Bug:** the Linux engine reports `hidden` for `GDK_WINDOW_STATE_ICONIFIED`, but xdg-shell has no minimized state,
so GTK 3 never sets it on Wayland. A minimized window reported only its focus loss, as `inactive`, and kept
rendering at the display rate (GNOME 46).
- **Workaround:** the `window` plugin's Linux `minimize` records the time, and a focus loss within 500 ms of it sends
`AppLifecycleState.hidden`. Its `window-state-event` handler is connected after the engine's, so that message
follows the engine's `inactive`; the compositor focuses the window when it restores it, which the engine reports as
`resumed`. A minimize the compositor starts itself (Super+H, the dock) is not covered.
- **Remove when:** the Linux engine reports minimized windows on Wayland, which needs GTK 4 or xdg-shell's
`suspended` state. Check in a GNOME Wayland session without the workaround: minimizing from the caption button
must log `AppLifecycleState.hidden`.
+40 -80
View File
@@ -4,21 +4,24 @@ FlClash is a multi-platform proxy client based on ClashMeta (mihomo), built with
## Version Notes
- Release CI pins Flutter 3.47.1. Local SDK may diverge, so trust the CI
version as the source of truth for release builds.
- Dart SDK constraint: `>=3.8.0 <4.0.0`. The lower bound is load-bearing and
must not be raised to the Dart version the SDK actually ships; see
Dependency Ceilings.
- Flutter stays on the 3.47.x line. Release CI pins 3.47.4 and is the source
of truth for release builds; `.fvmrc` and local SDKs may sit on another 3.47
patch. The editor in `plugins/code_forge` came from `code_forge` 10.14.0, which
needs Dart 3.13.2, so the CI pin cannot go below Flutter 3.47.2.
- Before moving the pin, walk `flutter-upgrade.md`: it lists the workarounds tied to
the current SDK, such as Impeller disabled on the Intel macOS release build.
- The Dart SDK lower bound in `pubspec.yaml` sets the package language
version. `flutter analyze` excludes `lib/**/generated/**`, so a raise is
proven by `flutter test`, which parses the generated code, not by analyze.
## Forked Dependencies
Three `pubspec.yaml` dependencies are pinned to a fork — `window_manager` by tag,
the other two by commit SHA. All three
forks live under `chen08209`, the same account that owns this repository, so they
are maintained in-house rather than tracked from a third party: advancing a pin
is a local decision, and there is no external maintainer to wait on for the patch
itself. What each fork still waits on is the *upstream* fix that would let the
pin be dropped entirely, recorded below.
Two `pubspec.yaml` dependencies are pinned to a fork by commit SHA. Both forks
live under `chen08209`, the same account that owns this repository, so they are
maintained in-house rather than tracked from a third party: advancing a pin is a
local decision, and there is no external maintainer to wait on for the patch
itself. What each fork still waits on is the *upstream* fix that would let the pin
be dropped entirely, recorded below.
Each entry records what the fork changes and what has to be true before it can go
back to the published package, so a future upgrade does not have to rediscover it.
@@ -29,25 +32,11 @@ of the same number:
diff -ru ~/.pub-cache/hosted/pub.dev/<name>-<version> ~/.pub-cache/git/<name>-<sha>
```
`window_manager` — `chen08209/window_manager`, path `packages/window_manager`,
version 0.5.1, pinned to the tag `v0.5.1-flclash.1` because the fork carries
commits of its own rather than a single patch on top of a release.
- `windows/window_manager_plugin.cpp`: with `titleBarStyle: hidden` a maximized
window uses the monitor work area (`GetMonitorInfo().rcWork`) instead of
upstream's `adjustNCCALCSIZE` border fudge, so it no longer covers the taskbar.
- `linux/window_manager_plugin.cc`: GTK drops the placement of an unmapped
window, so `hide` saves the geometry and the `map-event` handler applies it
again. Upstream only moves the window while it is hidden, which a window
manager is free to ignore — the window then reappears wherever it decides to
place it, which on this repository's Linux runner is every appearance after the
first, because `my_application.cc` never shows the toplevel itself.
- Adds `setWindowCornerPreference` (Windows) and `handleShouldTerminate` /
`onWindowShouldTerminate` (macOS). These lived in a local `window_ext` plugin
until they moved here; `lib/manager/window_manager.dart` and
`macos/Runner/AppDelegate.swift` are the callers.
- Drop the fork once upstream carries all three. The added APIs have call sites,
so this is not a pin change alone.
The `window_manager` fork (`chen08209/window_manager`, tag `v0.5.1-flclash.3`)
was replaced by the in-repo `plugins/window` package; the behaviors it carried
(work-area maximize with a hidden title bar on Windows, geometry restore on
re-map on Linux, corner preference, terminate and activate events) live there
now, see the window plugin section of `architecture.md`.
`launch_at_startup` — `chen08209/launch_at_startup`, version 0.5.1.
@@ -66,60 +55,31 @@ commits of its own rather than a single patch on top of a release.
- Drop the fork once upstream quotes map keys by the same
`isValidUnquotedString` rule it already applies to values.
The `code_forge` fork (`chen08209/code_forge`) is frozen: its Dart code now lives
in the in-repo `plugins/code_forge` package and its Rust code in the `editor/`
module of `plugins/rust_api`, both trimmed to what FlClash uses.
`plugins/code_forge/README.md` records what was kept and what was removed;
upstream's MIT notice stays in `plugins/code_forge/LICENSE`.
## Dependency Ceilings
Several dependencies cannot be advanced from this repository, and re-running
`flutter pub outdated` will keep listing them. The blocker is upstream in every
case, so treat the list as resolved-until-the-ceiling-moves rather than as debt:
`flutter pub outdated` lists only upstream-held entries, so treat them as
resolved-until-the-ceiling-moves rather than as debt:
`freezed` is pinned exactly to `3.2.6-dev.1`, which is a pre-release *ahead* of
the newest stable `3.2.5`. It is not a stale pin and must not be "fixed" by
moving to `3.2.5`: stable `3.2.5` requires `analyzer >=9.0.0 <11.0.0`, while the
pinned Flutter SDK resolves `analyzer` 12. `3.2.6-dev.1` is the only published
freezed release that accepts `analyzer` 12. Move to a stable release only once
one exists that accepts the analyzer the SDK actually resolves.
- `intl` is intentionally unbounded (`any`) and `material_color_utilities` is
resolved by the Flutter SDK; neither is a bound this repository sets.
The `>=3.8.0` Dart lower bound is a language-version floor, not a stale minimum.
Dart 3.13 makes `final` on a parameter an error, and `freezed` still emits it in
the constructors it generates for every collection field it backs with a private
field (`const _LogsState({final List<Log> logs = const [], ...})`); 4.0.0-dev.3
emits it too. A pubspec's lower bound sets the package language version, so
`>=3.8.0` keeps that generated code legal while the SDK itself runs 3.13. Raising
the bound makes every `*.freezed.dart` fail to parse, which `flutter analyze`
does not catch because `lib/**/generated/**` is excluded. Raise it only once
freezed stops emitting the modifier.
`freezed`, the `riverpod` set, `build_runner`, `drift_dev`, `intl_utils` and
`test` all resolve against the analyzer the pinned SDK ships; none of them is
held back. When an upgrade fails version solving, record the blocker here with
what has to change upstream before the pin can move, so the next
`flutter pub outdated` run does not rediscover it.
One `analyzer` ceiling holds most of the remaining `flutter pub outdated` list.
The newest `build_runner`, `drift_dev`, `intl_utils`, `test`, and
`riverpod_generator` all require `analyzer` 13; `test` 1.31.2 additionally
requires `test_api` 0.7.13, while `flutter_test` from the pinned SDK depends on
`test_api` 0.7.12. Raising any of those bounds therefore fails version solving.
The `riverpod` chain is the visible symptom. It is held at
`riverpod`/`flutter_riverpod` 3.3.2, `riverpod_annotation` 4.0.3, and
`riverpod_generator` 4.0.4 as one rigid unit, because `riverpod_annotation`
4.0.3 depends on `riverpod` exactly 3.3.2: the runtime cannot move without the
generator, and the generator cannot move without `analyzer` 13. Retry the whole
set after a Flutter SDK bump raises `test_api`, not before.
`intl` is intentionally unbounded (`any`) and `material_color_utilities` is
resolved by the SDK; neither is a bound this repository sets.
`isolate_contactor` is discontinued and `isolate_manager` is several majors
behind. Both arrive through `re_editor`, which pins `isolate_manager: ^4.1.5+1`
and is already at its own latest release. Nothing in this repository can advance
them.
`CorePalette` is deprecated in `material_color_utilities` in favour of
`DynamicScheme`/`CorePalettes`, but `dynamic_color` 1.9.0 — its newest release —
still exposes only `DynamicColorPlugin.getCorePalette()`, which returns the
deprecated type. There is no migration available from this repository short of
calling the `io.material.plugins/dynamic_color` method channel directly and
decoding the palette int list by hand, which is not worth owning. The deprecated
type is therefore confined to `GlobalState._initDynamicColor`, which converts it
to the two plain seed colours the app actually consumes; everything downstream
sees `DynamicColorSeeds`. Revisit when `dynamic_color` ships a non-deprecated
accessor — only that one function has to change.
`_initDynamicColor` in `lib/bootstrap.dart` reads the `dynamic_color` method
channel directly and decodes the palette list into a `TonalPalette`, instead of
going through `DynamicColorPlugin.getCorePalette()` and its deprecated
`CorePalette` return type. Everything downstream sees `DynamicColorSeeds`, so a
non-deprecated plugin accessor changes only that one function.
## Build Dependencies
+56 -28
View File
@@ -29,7 +29,9 @@ raw string.
### Corner Radius
Rounded corners are superellipses everywhere, not circular arcs. Use the superellipse API at each layer:
Rounded corners are superellipses (continuous corners) everywhere, never circular arcs; when a component offers
both, pick the superellipse. `test/lint/superellipse_corners_test.dart` fails on the circular APIs below in `lib/`. Use
the superellipse API at each layer:
- Shapes: `RoundedSuperellipseBorder` instead of `RoundedRectangleBorder`.
- Clips: `ClipRSuperellipse` instead of `ClipRRect`.
@@ -37,25 +39,31 @@ Rounded corners are superellipses everywhere, not circular arcs. Use the superel
`BoxDecoration(borderRadius: ...)`; borders move to the shape's `side`, and a `Container` with
`clipBehavior` still clips to the shape path.
- Canvas: `canvas.drawRSuperellipse(RSuperellipse.fromRectAndRadius(...))` instead of `drawRRect`.
- Ink: `InkWell.customBorder` with an `AppShape` instead of `InkWell.borderRadius`.
Passing `BorderRadius.circular(x)` as the `borderRadius` argument of these APIs is expected — it only
carries the corner magnitude; the rendered geometry stays a superellipse.
APIs that accept only `BorderRadius` keep circular corners, with the superellipse supplied by an
enclosing clip or shape where one is needed: `InkWell.borderRadius`, `OutlineInputBorder`,
`ScrollbarThemeData.radius`, and `smooth_sheets`' `MaterialSheetDecoration`. Fully round pills
(`BorderRadius.circular(999)` or half the shortest side) may stay circular — both geometries coincide
there.
Pills are no exception. `StadiumBorder` joins its round caps to the straight sides with a curvature jump, while
`AppShape.full`, a superellipse at full radius, blends them, and on a square it renders the exact circle, so it serves
circles too. Material 3 defaults every button, icon button, segmented button, and search bar to `StadiumBorder`;
`ThemeData.withAppShapes` replaces that for the button themes, and a local `FilledButtonTheme` or `IconButtonTheme`
replaces the app's, so merge the ambient `XButtonTheme.of(context).style` into it, as `CommonMinFilledButtonTheme`
does. `CircleBorder` and `BoxShape.circle` are fine for something that is always square.
APIs that accept only a `Radius`, such as `ScrollbarThemeData.radius`, keep circular corners; supply the superellipse
with an enclosing clip or shape where it shows.
CI gates formatting: `dart format --output=none --set-exit-if-changed lib test
tool plugins setup.dart` runs before `flutter analyze`.
Generated directories are excluded from analysis:
These directories are excluded from analysis:
- `build/**`
- `lib/l10n/intl/**`
- `lib/**/generated/**`
- `plugins/**`
- the platform runner directories: `android/`, `ios/`, `web/`, `windows/`, `macos/`, `linux/`
## Comments
@@ -136,9 +144,10 @@ leaving a repo-wide policy as a comment reaches only the reader of that one file
keeps its identity check on the Dart side, which compares the named-pipe peer PID with the Core it launched.
- Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, JNI, and
desktop IPC. Do not double-encode `arguments`, `result`, or event batches.
- `core/message.go` carries three event queues, and the split is load-bearing: state (loaded, geo-update), delay, and
bulk (log, request). Delay and bulk evict their own oldest entry under backpressure; state uses `enqueueState`, which
never evicts, because a dropped `geoUpdate{updating:false}` leaves `isUpdatingProvider` stuck at true in the UI until
- `core/message.go` carries three event queues, and the split is load-bearing: state (loaded, geo-update,
route-changed), priority (every other event), and bulk (log, request, DNS). Priority and bulk evict their own oldest
entry under backpressure, except DNS, which enters the bulk queue through `enqueueState` so a lookup burst cannot push
out the request events the connection list is built from; state uses `enqueueState`, which never evicts, because a dropped `geoUpdate{updating:false}` leaves `isUpdatingProvider` stuck at true in the UI until
`UpdatingAction` sweeps it as stale minutes later. Do not merge the tiers or give state eviction semantics. `enqueueState` drops silently on a full
queue and must stay that way: reaching it means the host stopped reading, which `logDeliveryError` already reports,
and reporting it from the message layer feeds the same batcher.
@@ -195,12 +204,11 @@ leaving a repo-wide policy as a comment reaches only the reader of that one file
mutual exclusion against each other and against `patchSelectGroup` — but not against a whole config apply, which is
what `configMu` made a proxy switch wait for, provider downloads included. `patchSelectGroup` takes `selectMu` under
`configMu`, fixing the order as `configMu` → `selectMu`.
- The delay-test semaphore is acquired with a slice of the caller's budget (`budget/delayTestQueueShare`), not
unconditionally and not with the whole deadline. Queueing and probing come out of one budget, so a test handed all of
it can spend it waiting and reach `URLTest` with nothing left, reporting a proxy it never contacted as unreachable.
The probe keeps the caller's original deadline, so whatever the queue did not use is still its own.
- Queueing for the delay-test semaphore and probing the node each get the full timeout in `handleTestDelay`. With one
shared deadline, a node that waited behind a saturated semaphore reached `URLTest` with almost nothing left, so a bulk
test of a large subscription reported whatever sat at the back of the queue as unreachable.
- A delay test that the Core does not answer is a fault of the Core or the channel, never a verdict on the proxy:
`handleTestDelay` returns inside its own budget on every path. `asyncTestDelay` therefore returns null instead of a
`handleTestDelay` returns within its two timeouts on every path. `asyncTestDelay` therefore returns null instead of a
`-1` delay, and `ProxiesAction` leaves the last measurement in place and abandons the rest of the run. Writing a
timeout there is what made a reachable node read as unreachable whenever the host deadline beat the Core's.
- Delay-test progress lives in `pendingDelayTestsProvider`, not as a sentinel value in `DelayDataSource`. A delay of 0
@@ -254,8 +262,14 @@ leaving a repo-wide policy as a comment reaches only the reader of that one file
actions through `ServiceState` and keep `ServiceController` as the sole binding/run-time owner.
- Every `BroadcastReceiver.goAsync()` path must finish its `PendingResult` exactly once. A watchdog may release the
broadcast lease, but must not cancel, reverse, or otherwise redefine the service operation.
- Anything that probes through the outbound route is a `RoutedProbe` judged against `routeTrackerProvider` (Route
Consistency in `.agents/architecture.md`); the Core stamps every answer and publishes its picks, so do not add a
change signal, a trigger counter, or a `coreStatusProvider` watch of your own.
- Presentation smoothing such as `CoreStatusButton`'s connecting hold must remain local display state. It must not delay or
overwrite `coreStatusProvider`, and a real failure must bypass/cancel the hold immediately.
- Safe mode (`SAFE_MODE` define, see the safe mode section of `.agents/architecture.md`) is consulted by each host
integration at its owner, never by rewriting the user's settings. `safeModeBuild` is for handles created before the
container exists; everything else reads `safeModeProvider` so a test can override it.
- `Tray.hide()` is idempotent on all three desktop platforms and returns native state to "`show` was never called".
`AppTray.shutdown()` latches, so no later `update()`/`updateTitle()` can resurrect the icon once shutdown begins.
Keep it that way; a resurrected icon outlives `exit(0)` as a Windows ghost icon, because `setPreventClose(true)`
@@ -299,8 +313,6 @@ connection `captureFrames` has installed by then. Either keep the event out of t
connection with `swapConn`; a bare assignment races every event it happens to be delivering, and `go test -race` catches
it in an unrelated test.
Use `CoreController.test(mock)` to inject a mocked `CoreHandlerInterface`. Call `CoreController.resetInstance()` in `tearDown` to clean up the singleton between tests.
Register fallback values for freezed params used with `any()` matchers.
`tool/check_coverage.dart` enforces a total floor passed by CI plus per-group floors declared in `_groupFloors`. Raise a
@@ -313,7 +325,8 @@ land.
Prefer `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))` over `CoreController.test(fake)` in new and
touched tests. `CoreController.test` claims the process-wide singleton, which makes a global read and a provider read
resolve to the same fake, so it cannot fail on a call site that still reaches for the global.
resolve to the same fake, so it cannot fail on a call site that still reaches for the global. A test that does claim
the singleton calls `CoreController.resetInstance()` in `tearDown`.
Construct the Android lib handler with `CoreLib.scoped(fakeService)`. The `service` global is gated on `Platform.isAndroid`
and is therefore null on every test host, so a `CoreLib()` built from it silently takes the null-service fallback on every
@@ -326,10 +339,10 @@ key), so a test that skips the seam registers the test binary on the machine tha
under `kDebugMode`, which is always true beneath `flutter test`, so its remaining branches cannot be reached from a test
at all; `test/common/launch_test.dart` pins the early return instead.
`pumpAndSettle` never returns on a page holding `EditorPage`: the code editor blinks its caret forever, so frames keep
being scheduled. Pump explicitly instead. `encodeYamlTask` and its neighbours in `common/task.dart` hand work to a real
isolate through `compute`, which only runs outside the fake-async zone, so a test awaiting one needs
`tester.runAsync(...)` between the pumps — see `test/views/profile_preview_test.dart`.
`pumpAndSettle` never returns on a page holding `EditorView` (what `EditorPage` embeds): the code editor blinks its
caret forever, so frames keep being scheduled. Pump explicitly instead. `encodeYamlTask` and its neighbours in
`common/task.dart` hand work to a real isolate through `compute`, which only runs outside the fake-async zone, so a test
awaiting one needs `tester.runAsync(...)` between the pumps.
The `@visibleForTesting` `database` setter in `lib/database/database.dart` deliberately does not close the instance it
replaces. Tests inject `NativeDatabase.memory()`, which holds no file handle, and `Database.close()` is async while the
@@ -359,7 +372,7 @@ The same seam carries the two other places a platform decision changed what was
takes nullable `isAndroid`/`isMacOS` that fall back to the host. Every test names the platform it means, because
`debugDefaultTargetPlatformOverride` does not move `system` and a suite that leaves it to the host asserts the macOS
shape on a developer machine and the Linux one on CI. `WindowHeaderContainer` builds the caption buttons on every
non-macOS host, so a test mounting it needs `TestApp` for `AppLocalizations` and a `window_manager` channel mock that
non-macOS host, so a test mounting it needs `TestApp` for `AppLocalizations` and a `window` channel mock that
answers `isMaximized`/`isAlwaysOnTop` with a bool.
Auto-dispose providers need a container-level hold before a test reads them back. `proxyGroupProvider`, `ruleProvider`,
@@ -405,7 +418,10 @@ throws "`…State.dispose failed to call super.dispose.`" in debug and profile b
dispose()` and hand async teardown to `unawaited(...)`; `Future<void> dispose() async` compiles and is the shape that
invites the bug.
Use `ProviderContainer` directly for simple Riverpod provider tests. The generated Riverpod `update()` method takes a callback:
Use `ProviderContainer` directly for simple Riverpod provider tests, released with `addTearDown(container.dispose)`.
Assert against the provider's generated public API, not its implementation, re-read source defaults before asserting
them, and wait on provider futures or state changes rather than fixed sleeps. The generated Riverpod `update()` method
takes a callback:
```dart
notifier.update((state) => newValue);
@@ -416,6 +432,17 @@ When testing freezed models with nested objects, always round-trip through `json
For async widgets, put visual cleanup in `finally` when the action may throw. Focused widget tests should cover success,
failure, disposal, and any timer boundary that changes visible state.
## Running the App Locally
- An agent starts FlClash on the host only as a safe mode build (`--dart-define=SAFE_MODE=true`, see
`.agents/commands.md`). A normal build takes over the host network, so the user runs it, never the agent.
- Never end a FlClash process the agent did not start, and never kill by name (`pkill`, `killall`, a pattern on the
executable path): the user's own instance matches too. Record the pid of every process you launch, check with
`ps -o pid,command -p <pid>` that its executable is your own build output, and end only that pid.
- Only a macOS or Android debug build has its own data directory; every other build shares the installed app's data
directory and single-instance lock, so it cannot start while the user's instance runs. `.agents/commands.md` lists
which builds are kept apart.
## Commit Messages
Subjects follow Conventional Commits and are enforced by the `commit-msg` hook in `.pre-commit-config.yaml`, which runs
@@ -452,16 +479,17 @@ Changelog: Per-profile override scripts
```
- `Changelog:` is the English entry. `Changelog: skip` drops the commit from the changelog entirely.
- The changelog is English only. Translation trailers were removed on purpose: they pushed release copy into the commit
history, so any `Changelog-<locale>:` or `Breaking-<locale>:` now fails the hook. Translate after the fact if ever
needed, not in the commit message.
- The changelog is English only. A `Changelog-<locale>:` or `Breaking-<locale>:` trailer fails the hook, because
translated release copy does not belong in the commit history. Translate after the fact if ever needed, not in the
commit message.
- `Changelog-Type:` moves an entry into another group, for example to promote a `refactor` that users will notice. Valid
values are `breaking`, `feat`, `fix`, `perf`, `revert`.
- `BREAKING CHANGE:` is required whenever the subject carries `!`, and its text becomes the breaking entry. A `!` commit
therefore needs two lines of copy: the footer for the breaking entry and `Changelog:` for the normal one.
`feat`, `fix`, `perf`, `revert` and breaking commits are collected by default; every other type is dropped unless it
carries a `Changelog:` trailer. Commits missing a trailer reuse their subject, and the hook says so without blocking.
carries a `Changelog:` trailer. Commits missing a trailer reuse their subject; for `feat`, `fix` and `perf` the hook
says so without blocking.
## Generated Code
+9 -3
View File
@@ -1,18 +1,24 @@
# Agent Skills
Repo-scoped Codex skills live under `.agents/skills/*/SKILL.md`. Codex can discover these by skill `name` and `description`, then load the full instructions only when a task matches.
Repo skills live under `.agents/skills/*/SKILL.md`, where Codex discovers them directly. Claude Code discovers them
through the symlinks in `.claude/skills/`, one per skill, pointing back at the `.agents/skills/` directory. Both tools
match a task against the skill `name` and `description`, then load the full instructions only when it matches.
## Available Repo Skills
- `localization`: hardcoded UI text scans, ARB updates, locale generation, and localization verification.
- `provider-tests`: Riverpod provider, notifier, and state-management tests.
- `ui-work`: Flutter UI, widgets, Material You styling, navigation surfaces, async feedback, and user-facing interactions.
- `core-platform`: Core lifecycle/process ownership, Android services, Go event delivery, desktop IPC, platform managers,
VPN/TUN, and Windows Helper flow.
- `pre-commit-quality-review`: lightweight triage before commits, with scoped structural review and user decisions on concrete regressions.
## Authoring Notes
- Add new repeatable workflows as `.agents/skills/<skill-name>/SKILL.md`.
- Add new repeatable workflows as `.agents/skills/<skill-name>/SKILL.md`, and add the matching
`.claude/skills/<skill-name>` symlink so Claude Code sees it too; remove both when retiring a skill.
- Git stores the symlinks as links only where the checkout supports them. A Windows checkout needs
`core.symlinks=true` and Developer Mode, otherwise the links become plain files and Claude Code loads no repo
skill there; Codex is unaffected because it reads `.agents/skills/` directly.
- Keep skill descriptions trigger-focused and start them with `Use when...`.
- Keep long reference material in `.agents/*.md`; skills should link to it instead of duplicating it.
- Put command permission rules in `.codex/rules/*.rules`, not in this file.
+48 -58
View File
@@ -1,73 +1,63 @@
---
name: core-platform
description: Use when changing FlClash Core integration, lifecycle/process ownership, Go event delivery, Android services, desktop IPC, platform managers, VPN/TUN, or Windows Helper flow.
description: Use when changing FlClash Core integration, lifecycle/process ownership, Go event delivery, Android services, desktop IPC, platform managers, build hooks, system proxy, tray, VPN/TUN, or the Windows or Linux Helper.
---
# Core And Platform
## When To Use
Use this for changes touching `lib/core/`, `lib/manager/`, `core/`, `services/helper/`, Android app/service modules, build
hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior.
Use this for changes touching `lib/core/`, `lib/manager/`, `core/`, `services/helper/`, Android app/service modules,
build hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior.
## Workflow
## Read First
1. Identify the authoritative owner before changing behavior:
- Shared facade/protocol: `lib/core/controller.dart`, `lib/core/interface.dart`, and `lib/core/method.dart`.
- Android Core connection: `lib/core/lib.dart`, `lib/plugins/service.dart`, and Android `ServicePlugin`.
- Android start/stop intent: `ServiceState`; binding/process-time bookkeeping: `ServiceController`.
- Desktop composition: `lib/core/service.dart`; lifecycle/process ownership: `lib/core/desktop/lifecycle.dart`.
- Desktop IPC/RPC: `lib/core/desktop/transport.dart` and `lib/core/desktop/rpc_client.dart`.
- Desktop launch ownership: `lib/core/desktop/launcher.dart`; Windows Helper HTTP contract:
`lib/core/desktop/helper_client.dart` and `services/helper/`.
- Flutter orchestration: `lib/providers/actions/core.dart` and `system.dart`; UI/event observation: `lib/manager/`.
2. Trace every entry path into that owner, including UI/provider calls, Quick Settings, notification actions, Always-on VPN,
revoke callbacks, application exit, and crash/disconnect recovery. Lifecycle callbacks are not implicit user intent.
3. Preserve latest-intent semantics:
- Desktop revisions converge to running/restarted/stopped/closed and report applied/coalesced/superseded outcomes.
- Android Flutter calls stay optimistic; `ServiceState` identity-checks the latest native `RunRequest`.
4. Route feature calls through `CoreController` and `CoreHandlerInterface`. Do not bypass desktop process leases or create a
second Android service binding owner.
5. Keep JSON envelopes and event shapes identical across Dart, Go, JNI, and desktop IPC. If event traffic changes, preserve
the separate priority and bulk queues in `core/message.go`.
6. Keep shutdown single-owned and terminal. `SystemExitCoordinator` sequences resource cleanup, window close, Core close,
and process exit; widget/manager disposal must not race it.
7. Add or update focused tests at the narrowest layer, then run the matching commands from `.agents/commands.md`:
- Desktop lifecycle/transport/RPC: `test/core/desktop/` plus `test/core/service_test.dart`.
- Cross-language envelopes/events: `test/core/protocol_contract_test.dart` and `CGO_ENABLED=0 go test .`.
- Provider/exit convergence: `test/providers/action_test.dart` and `test/providers/system_action_test.dart`.
- Android Kotlin: compile each touched Gradle module with JDK 17.
- Windows Helper: Cargo format/tests; run the `windows-service` feature on Windows.
8. Explicitly state host gaps. Always-on VPN, VPN permission, system revoke, named-pipe peer identity, and Windows Service
Control Manager behavior need their real platform even when portable tests pass.
- `.agents/rules.md`: Core API Safety and Lifecycle Rules are binding. They carry the mihomo-fork patch budget, the
JSON envelope and `core/message.go` queue rules, JNI ownership, `BootGuard`, tray, and SSID rules, and every
lifecycle invariant (latest intent wins, desktop ownership through a `CoreProcessLease`, terminal `close()`,
optimistic Android commands arbitrated by `ServiceState`, callbacks are not user intent, `goAsync()` finishes once).
- `.agents/architecture.md`: Core Integration, Lifecycle Ownership And Convergence, Core Protocol And Event Delivery,
Safe Mode, Build System, and Rust Helper Service describe the current mechanisms, including the Helper protocol,
the applied/coalesced/superseded outcomes, and the unconfirmed-exit lease.
## Reference Files
## Owners
Read `.agents/architecture.md` for the current core modes, manager stack, build hooks, local plugins, and Windows helper notes.
Every behavior here has one authoritative owner, and a change lands there:
- Shared facade/protocol: `lib/core/controller.dart`, `lib/core/interface.dart`, and `lib/core/method.dart`.
- Android Core connection: `lib/core/lib.dart`, `lib/plugins/service.dart`, and Android `ServicePlugin`.
- Android start/stop intent: `ServiceState`; binding/process-time bookkeeping: `ServiceController`.
- Desktop composition: `lib/core/service.dart`; lifecycle/process ownership: `lib/core/desktop/lifecycle.dart`.
- Desktop IPC/RPC: `lib/core/desktop/transport.dart` and `lib/core/desktop/rpc_client.dart`.
- Desktop launch ownership: `lib/core/desktop/launcher.dart`; Windows and Linux Helper HTTP contract:
`lib/core/desktop/helper_client.dart` and `services/helper/`.
- Flutter orchestration: `lib/providers/actions/core.dart` and `system.dart`; application exit:
`SystemExitCoordinator`; UI/event observation: `lib/manager/`.
An owner has more entry paths than the UI: provider calls, Quick Settings, notification actions, Always-on VPN, revoke
callbacks, application exit, and crash/disconnect recovery all reach it. Always-on startup is explicit through
`VPN_START_REQUESTED` and revoke through `VPN_REVOKED`; service creation or destruction is never read as intent.
## Verification
Focused tests live at the narrowest layer, and the matching commands are in `.agents/commands.md`:
- Desktop lifecycle/transport/RPC: `test/core/desktop/` plus `test/core/service_test.dart`.
- Cross-language envelopes/events: `test/core/protocol_contract_test.dart` and `CGO_ENABLED=0 go test .`.
- Provider/exit convergence: `test/providers/action_test.dart` and `test/providers/system_action_test.dart`.
- Android Kotlin: compile each touched Gradle module with JDK 17.
- Helper: `cargo fmt --check` and `cargo test`; the `windows-service` feature only builds on Windows.
State host gaps explicitly. Always-on VPN, VPN permission, system revoke, named-pipe peer identity, polkit and systemd
on Linux, and Windows Service Control Manager behavior need their real platform even when portable tests pass.
## Pitfalls
- Keep the Windows Helper protocol and Core SHA256 validation identical across
Flutter build modes; the Helper owns executable integrity checks.
- Protocol version 6 uses a 32-character lowercase-hex session ID. `/start` must return the submitted session and PID;
`/stop` must never terminate a different session; Dart must verify the connected named-pipe peer PID.
- `/start` must release the previously managed Core before it verifies, so no `/start` outcome leaves a Helper-managed
Core behind for the caller's direct-launch fallback to race.
- The Helper owns a managed Core until its exit is confirmed. A `200` from `/stop` means the Core is gone; when
termination cannot be confirmed the Helper keeps the child and answers `coreStopFailed`, and `/start` reports the same
code instead of spawning a replacement. Keep that code out of the Dart pre-spawn fallback set in
`helper_client.dart`, or the direct launch will race a Core the Helper still owns.
- TUN is not a required run condition. Degrading to the unelevated direct Core — and silently losing TUN — is the
expected outcome whenever the Helper path fails; do not fail the launch instead.
- A desktop process lease with unconfirmed exit must remain owned until cleanup succeeds. Do not discard it and start a
replacement Core.
- `CoreController.close()` is terminal. Do not call it from a reusable manager lifecycle or recover by starting it again.
- `ServiceBroadcastReceiver.goAsync()` must finish once even on timeout; its watchdog releases the broadcast only and must
not become a service timeout.
- Do not interpret service creation/destruction as start/stop intent. Always-on startup is explicit through
`VPN_START_REQUESTED`; revoke is explicit through `VPN_REVOKED`.
- Keep log/request floods from evicting state-bearing Core events. Each queue may evict only its own oldest item.
- Do not expose direct filesystem deletion APIs through Core or helper IPC; use
a scope-specific cleanup API instead.
- `plugins/setup/` is a build harness, not a Dart API plugin.
- Build hooks can trigger Go or Rust compilation indirectly through Flutter platform builds.
- A `200` from Helper `/stop` means the Core is gone. When termination cannot be confirmed the Helper keeps the child
and answers `coreStopFailed`, and `/start` reports the same code instead of spawning a replacement. Keep that code
out of the Dart pre-spawn fallback set in `helper_client.dart`, or the direct launch races a Core the Helper still
owns.
- The Helper owns executable integrity: Core SHA256 validation and the protocol stay identical across Flutter build
modes, so a debug build is not exempt.
- `plugins/setup/` is a build harness, not a Dart API plugin, and its hooks can trigger Go or Rust compilation
indirectly through Flutter platform builds; a missing toolchain fails `flutter test`, not only `flutter build`.
+11 -11
View File
@@ -13,33 +13,33 @@ Do not use this for README translation sync or manual edits to generated localiz
## Workflow
1. Confirm `pubspec.yaml` still uses `flutter_intl`, source ARBs under `arb/`, and generated output under `lib/l10n/`.
2. Scan user-facing Dart text before opening many files:
1. Scan user-facing Dart text before opening many files. `flutter_intl` reads `arb/` and writes `lib/l10n/`:
```bash
rg -n "[\\p{Han}]" lib -g '!lib/l10n/intl/**' -g '!lib/**/generated/**'
```
3. Inspect the smallest relevant call sites and nearby ARB keys.
4. Add or update every source ARB:
2. Inspect the smallest relevant call sites and nearby ARB keys.
3. Add or update every source ARB:
- `arb/intl_en.arb`
- `arb/intl_zh_CN.arb`
- `arb/intl_ja.arb`
- `arb/intl_ru.arb`
5. Replace inline strings with existing project accessors:
- Widgets with `BuildContext`: `context.appLocalizations.key` from `common.dart`.
- Controllers/providers/non-widget code: `currentAppLocalizations.key` from `app_localizations.dart`.
6. Regenerate:
4. Replace inline strings with existing project accessors:
- Widgets with `BuildContext`: `context.appLocalizations.key` from `lib/common/context.dart`.
- Controllers/providers/non-widget code: `currentAppLocalizations.key` from `lib/common/app_localizations.dart`.
5. Regenerate:
```bash
dart run intl_utils:generate
```
7. Verify changed Dart files with `flutter analyze` when practical.
8. Re-run the Han-text scan for targeted strings.
6. Run `flutter analyze --no-fatal-infos` and `flutter test test/l10n test/lint/dynamic_message_key_test.dart`. The
lint test covers the labels built from a runtime key, such as `action_${HotAction.name}`, which the analyzer cannot
see; the ARB section of `.agents/rules.md` explains them.
7. Re-run the Han-text scan for targeted strings.
## Pitfalls
- If ja/ru still show English, fix ja/ru source ARB values and regenerate. Do not edit generated Dart.
- Ignore `lib/l10n/intl/**` and `lib/**/generated/**` during text scans.
- If generator or analyzer hits local cache permission friction, rerun serially before treating it as a code issue.
@@ -0,0 +1,105 @@
---
name: pre-commit-quality-review
description: Use when the user asks to commit, amend, or squash code, or requests a pre-commit structural quality review.
---
# Pre-commit Quality Review
Check that iterative edits converged into a clear, cohesive, maintainable implementation before they become a commit,
so later code review is not spent on basic structure. `AGENTS.md` and `.agents/rules.md` apply throughout. This skill
grants no authority: commit, push, and history rewrites stay under the user's existing authorization, and silence is
not consent.
## Triage
Most commits stop here. Decide from the change summary, the diff, and the local context it needs; never start with a
repository-wide scan.
- **Skip** formatting, mechanical renames, generated output, copy changes, and simple local fixes. They go through the
normal commit flow without extra confirmation. Say the review was skipped, never that it passed.
- **Review** when a core implementation is replaced; when responsibilities, dependency direction, or public abstractions
change materially; when accumulated patches added branches, duplicate paths, or indirection; or when new code draws
responsibility boundaries. A small change can move a critical responsibility, so line count is not a trigger.
- An explicit structural review request bypasses triage.
The snapshot under review is the intended commit compared with its baseline:
| Operation | Baseline |
|---|---|
| Regular commit | `HEAD` |
| Amend | Parent of the commit being replaced |
| Squash | Version preceding the agreed range |
| Initial commit | Empty tree |
Ask when the range or parent is materially ambiguous instead of assuming the whole branch. Read staged versions of
partially staged files; an unstaged fix is not part of the pending commit. Preserve unrelated changes and partial
staging.
## Review the Final Structure
Scope the review to the responsibility boundaries this change touched. Read the complete final implementation of the
affected units and compare before and after, not only the last patch. Report issues this change introduced or
worsened; untouched historical debt is not a pre-commit requirement. Follow a call or dependency path only as far as a
concrete concern needs, and disclose missing essential context rather than claiming a complete pass.
Focus on:
- **Responsibilities and cohesion:** units mixing responsibilities that change independently; orchestration tangled
with implementation detail; responsibilities in layers that should not own them.
- **Dependencies and abstractions:** reversed or circular dependencies, forwarding layers, abstractions that isolate
nothing; similar names or interfaces hiding different responsibilities.
- **Complexity and readability:** nested conditions, repeated fallbacks, boolean parameter combinations, mutually
dependent helpers; names and decomposition that do not reduce reading effort.
- **Duplication and consistency:** reimplemented existing capabilities, parallel implementations or duplicate state,
scattered ownership of error handling, cleanup, or state updates.
- **Remnants:** obsolete branches, compatibility layers, unused parameters, commented-out code, comments the rewrite
invalidated; coupling that makes existing test boundaries hard to use.
Not findings: length, a missing design pattern, naming taste, or anything without code evidence and a real
maintenance cost. Do not add cross-layer coupling to remove a few similar lines, rewrite stable code for uniformity,
or demand abstractions for speculative needs.
Business logic, UI behavior, performance, security, platform compatibility, and full functional review are out of
scope. Report an incidentally found serious defect separately through the normal defect workflow, without hiding it or
widening this review.
## Findings and the User's Decision
A finding qualifies only when all three hold:
- Before-and-after code and the necessary call relationships show this change introduced or worsened it.
- The maintenance cost is concrete, such as updating one rule in two places or touching unrelated layers for one
responsibility. Clarity or elegance alone is not enough.
- A minimal proposal fits existing constraints, with a benefit not offset by new coupling, indirection, or scope.
With no qualifying finding, state the reviewed scope and conclusion in a sentence or two and continue under the
existing commit authorization. Do not ask for confirmation because a review happened, and do not collect optional
improvements into a suggestion list.
With qualifying findings, finish the read-only review and independent checks first, merge findings that share a cause,
and give for each: file, symbol, and location with how this change introduced it; the concrete maintenance cost; the
smallest proposal with its scope and any behavioral impact. Then **pause before creating the commit and ask whether to
optimize first; do not edit or commit until the user answers.** Attribute the pause to this skill by file path so the
user recognizes their configured workflow, for example: "The structural issues above support consolidating the
duplicate state-update paths. Should I make that change before committing?"
Existing explicit authorization for these findings is enough to act without asking again. The user may accept all,
some, or none. Keep accepted findings and tradeoffs in the task context only, and do not re-ask because files were
restaged, a hash changed, or unrelated edits landed; reassess only when relevant code changes invalidate the tradeoff.
## Resolve and Finish
Once authorized, make the smallest adjustments that resolve the accepted findings while preserving behavior and task
scope; explain separately if either must change. Verify only the accepted findings and the direct effects of the
adjustments, without another search for improvements. Raise a new finding only with evidence that meets the threshold
and exceeds the existing authorization.
Before committing, recheck status and the actual staged diff so the pending snapshot matches what was reviewed, and
review only the delta of later edits. Run the checks `AGENTS.md` requires for the change: `flutter pub get`,
`flutter analyze --no-fatal-infos`, and the covering `flutter test` suites listed in `.agents/commands.md`. Reuse
results that are still valid, rerun those a later edit invalidated, and never present results from an earlier snapshot
as verification of the staged one. The `comment-density` and `commit-msg` hooks in `.agents/rules.md` still gate the
commit.
Report the reviewed scope, completed improvements or accepted tradeoffs, check results, and any verification limits.
Claim the commit is done only after it exists.
-37
View File
@@ -1,37 +0,0 @@
---
name: provider-tests
description: Use when adding or updating FlClash Riverpod provider tests, notifier tests, or state-management tests in this repository.
---
# Provider Tests
## When To Use
Use this for tests under `test/providers/` or any change that validates Riverpod providers, generated notifiers, app state defaults, or provider interactions.
For broader test expansion, pair this with `.agents/rules.md` and `.agents/commands.md`.
## Workflow
1. Read the provider under test and its generated public API before writing assertions.
2. Use `ProviderContainer` directly when no widget tree is needed.
3. Dispose containers in teardown or with `addTearDown(container.dispose)`.
4. Prefer generated notifier APIs over implementation details. Generated `update()` takes a callback:
```dart
notifier.update((state) => newValue);
```
5. Mock external dependencies with `mocktail`; register fallback values for freezed params used with `any()`.
6. Keep tests focused on behavior: defaults, state transitions, persistence boundaries, and side effects.
7. Run the narrowest relevant test first:
```bash
flutter test test/providers/
```
## Pitfalls
- Do not use `dart test`; FlClash models and provider tests may depend on Flutter types.
- Re-check source defaults before asserting them; provider defaults can drift.
- If async provider timing matters, wait on provider futures or state changes instead of fixed sleeps.
+69 -73
View File
@@ -1,42 +1,47 @@
---
name: ui-work
description: Use when changing FlClash Flutter UI, widgets, screens, Material You styling, navigation surfaces, async feedback, or user-facing interactions.
description: Use when changing FlClash Flutter UI, widgets, screens, Material You styling, icons or glyphs, shapes and corner radii, navigation surfaces, async feedback, or user-facing interactions.
---
# UI Work
## When To Use
Use this for user-facing Flutter UI changes in `lib/`, including widgets, screens, navigation surfaces, settings rows, dialogs, and interaction behavior.
Use this for user-facing Flutter UI changes in `lib/`, including widgets, screens, navigation surfaces, settings rows,
dialogs, and interaction behavior.
## Workflow
## Conventions
1. Locate existing nearby widgets and reuse their patterns before adding new abstractions.
2. Follow current Material You and Surfboard-like visual conventions.
3. Use existing providers, notifiers, and helpers where possible.
4. Keep `child:` last in widget constructors.
5. Prefer `const` constructors and final locals.
6. Localize user-facing text through ARB; use `localization` when text changes are non-trivial.
7. Add focused widget tests when behavior changes, especially for rendering states, taps, scrolling, and empty/error states.
8. For asynchronous controls, define separately:
- authoritative provider/domain state;
- display-only state such as a minimum progress duration;
- tap policy while work or display holds are active;
- failure/disposal cleanup, normally in `finally` for animations and timers.
9. Run targeted verification:
```bash
flutter analyze
flutter test test/widgets/
```
- Reuse the patterns of nearby widgets, providers, notifiers, and helpers before adding an abstraction, and keep to the
Material You, Surfboard-like look the rest of the app has. Do not introduce a visual system for one screen or rewrite
a layout the request does not need.
- `.agents/rules.md` is binding here: the lint set (`child:` last, `const`, final locals), the superellipse corner API,
and the widget-test facts under Testing Rules (`pumpAndSettle` never returns on `EditorView`, `dispose()` must not
await, auto-dispose providers need a container-level hold, platform decisions are passed in as parameters).
- Settings screens are built from the `ConfigToggleItem` family in `lib/widgets/config_item.dart`; the Settings Rows
section of `.agents/architecture.md` says when a file-local helper or a named widget is warranted.
- Every icon lives in `lib/icons/`, never the Material `Icons` font: reuse or add a glyph in `AppGlyphs` and show it
with `GlyphIcon`, or with `AnimatedGlyph` where it fills on selection. Glyphs are outlined, except inside a filled
button, where they take `fill: 1`. The Icons section of `.agents/architecture.md` covers shape roles, the stroke, the
fill animation, and when to fill.
- Modals dim what they cover with the Material 3 scrim (`ColorScheme.modalScrim`, `scrim` at 32%) and never blur it.
Every modal brings its own scrim, including one opened from inside another, so do not make a barrier transparent to
keep scrims from stacking.
- App bar buttons reach `CommonScaffold` as data (`primaryAction`, `iconActions`, `menuItems`) so it can fold them into
its overflow menu; App Bar Actions in `.agents/architecture.md` has the rule.
- User-facing text goes through ARB; use `localization` when text changes are non-trivial.
- An asynchronous control keeps four things apart: authoritative provider/domain state; display-only state such as a
minimum progress duration; tap policy while work or a display hold is active; and failure/disposal cleanup, in
`finally` for animations and timers. `CoreStatusButton` and the proxies delay test action are the reference implementations,
described under User-Facing Core And Delay Feedback in `.agents/architecture.md`.
## Corner Radii
All corner radii come from `lib/common/shape.dart`. Never write a radius literal in a widget.
All corner radii come from `lib/common/shape.dart`: a token, a derived value, or a named off-scale value passed to its
builders. Never write a bare radius literal at a call site.
The scale is picked by the component's **shortest side**, not by what looks good in isolation. A radius that reads as
a soft card at 64 logical pixels tall reads as a pill at 24 and as a square at 400, so a single radius everywhere is
the wrong kind of consistency.
The scale is picked by the component's **shortest side**. A radius that reads as a soft card at 64 logical pixels tall
reads as a pill at 24 and as a square at 400, so one radius everywhere is the wrong kind of consistency.
| Token | Value | Shortest side | Use |
| --- | --- | --- | --- |
@@ -47,63 +52,54 @@ the wrong kind of consistency.
| `lg` | 20 | 64 to 180 | mid-size cards: dashboard tiles, proxy node cards |
| `xl` | 24 | full-bleed | anything spanning the whole width: list rows, grouped runs, group headers |
| `xxl` | 28 | over 200 | sheets, dialogs, full-screen containers |
| `full` | 1000 | - | pills and circles: tracks, indicators, progress, avatars |
| `full` | 1000 | - | pills and circles: buttons, tracks, indicators, progress, avatars |
`AppCorner.fit(shortestSide)` applies that table at runtime and is the right call whenever the size comes from a
`LayoutBuilder` or scales with text size. It snaps to the largest token that stays at or under one third of the
shortest side, which is the rule the table encodes.
- `AppCorner` holds the scale as `double`; `AppRadius` mirrors it as `BorderRadius` with `all`, `top`, and `vertical`
builders; `AppShape` mirrors it as `RoundedSuperellipseBorder`, `full` included, plus `circle`, `input`, and the
`all`/`top`/`vertical`/`of` builders. Use `AppShape.full` for pills, never `StadiumBorder`; the Corner Radius
section of `.agents/rules.md` explains why and lists the superellipse API per layer.
- `AppCorner.fit(shortestSide)` snaps to the largest token at or under one third of the shortest side. Use it whenever
the size comes from a `LayoutBuilder` or scales with text.
- `ThemeData.withAppShapes` in `lib/common/shape.dart` already applies the scale to card, dialog, bottom sheet, snack
bar, chip, menu, input, FAB, navigation indicator, progress, and the filled, text, outlined, elevated, and icon
button themes. Do not restate those shapes at call sites, and
leave `InputDecoration.border` unset so inputs inherit `AppShape.input`.
- `AppCorner` holds the scale as `double`, for `radius:` on `CommonCard` and for arithmetic.
- `AppRadius` mirrors it as `BorderRadius`, plus `all`, `top`, and `vertical` builders.
- `AppShape` mirrors it as `RoundedSuperellipseBorder`, plus `full` (stadium), `circle`, `input`, and the
`all`/`top`/`vertical`/`of` builders.
- `ThemeData.withAppShapes` in `lib/application.dart` applies the scale to card, dialog, bottom sheet, snack bar,
chip, menu, input, FAB, navigation indicator, and progress themes. Do not restate those shapes at call sites; in
particular, leave `InputDecoration.border` unset so inputs inherit `AppShape.input`.
`xl` and `lg` exist because one token cannot serve both size classes. A full-bleed surface is wide and short, so its
corner must be larger than its height alone suggests or it stops reading as a card; everything full-bleed shares `xl`
(`CommonSelectedListItem`, the profiles card, the outer corners of a grouped run in `DecorationListItem`, the proxy
group header in `lib/views/proxies/list.dart`) and does not use `fit()`. This is the one deliberate overshoot of the
one-third rule; the value stays under half a standard row so nothing clamps and every full-bleed surface renders the
same radius. `lg` is for mid-size cards that are not full-bleed: a proxy node card at `ProxyCardType.min` is only about
64 tall, which caps its radius at 21, so `xl` would break on a setting the user can change at any time while `lg`
clears all three heights and the smallest dashboard tile.
The three tiers between `md` and `xxl` each answer to a different size class, and the split is what keeps one radius
from being wrong for two of them.
Outlined inputs use `AppInputBorder`, not `ShapedInputBorder` from `package:material_ui`. The package border subtracts
the floating label's notch as a flat band along the top edge, which takes the corner curve with it at any radius from 8
up. `AppInputBorder` clips the notch region and paints the full superellipse through it, as Flutter's own
`OutlineInputBorder` does by shortening the arc. It needs no `contentPadding` compensation.
`xl` is for a surface that spans the full width. A wide, short strip needs a larger corner than its height alone
suggests, or it stops reading as a card. Everything full-bleed shares this token and must stay on it:
`CommonSelectedListItem`, the profiles card, the outer corners of a grouped run in `DecorationListItem`
(`generateSectionV3`), and the proxy group header in `lib/views/proxies/list.dart`. The value is deliberately under
half the height of a standard row, so nothing gets clamped and every one of them renders at the identical radius no
matter which is taller. This is the one place the one-third rule is knowingly overshot; the width is what carries
it, and `fit()` is not used here.
Nested radii are derived, never tokens: concentric corners need `outer = inner + inset`, so name the inset and compute
the outer value, as `_cardRadius` in `lib/widgets/popup.dart`, `thumbRadius + _kThumbInset` in `lib/widgets/tab.dart`, and the
selection ring in `lib/widgets/palette.dart` do. Adding a token for one of these is how a scale grows without bound.
`lg` is for the mid-size card that is not full-bleed: dashboard tiles and proxy node cards. It exists because `xl`
does not fit all of them. A proxy node card's height follows the user's `ProxyCardType` setting, and at `min` it is
only about 64 tall, which caps its radius at 21 — so `xl` would break on a setting the user can change at any time,
while `lg` clears every one of the three heights. The same value has room to spare on the smallest dashboard tile,
about 177x80, so both surfaces can hold one token instead of branching per size.
A component the scale does not fit keeps its own value instead of growing the scale: name it once in the file and pass
it to the `all`/`top`/`vertical` builders, as `_itemShape` in `lib/widgets/sidebar.dart` does with 10, between the
chip-sized `sm` and the near-pill `md` on a 40 tall row. Reach for this only once the table and `fit()` are ruled out.
Outlined inputs use `AppInputBorder`, not `ShapedInputBorder` from `package:material_ui`. That one subtracts the
floating label's notch from the outline as a two-pixel band along the top edge, which assumes the label sits over a
flat run of border. Any radius from 8 up puts the corner curve under the notch instead, the subtraction takes the
corner with it, and the top edge is left drawn a pixel low. `AppInputBorder` clips the notch region away and paints
the full superellipse through it, so the corner arc is truncated exactly where the notch begins. That is what
Flutter's own `OutlineInputBorder` does by shortening the corner arc's sweep, which is why the framework border
survives large radii and the package one does not. The border needs no `contentPadding` compensation; leave Material's
defaults alone.
## Verification
Nested radii are derived, never tokens. Concentric corners need `outer = inner + inset`, so name the inset and
compute the outer value: `_cardRadius` in `lib/widgets/popup.dart`, `_kCornerRadius` in `lib/widgets/tab.dart`, and
the selection ring in `lib/widgets/palette.dart` all do this. Adding an intermediate token to spell one of these out
is what makes a scale grow without bound.
- A behavior change gets a focused widget test covering rendering states, taps, scrolling, and empty/error states,
including the exception path of any async action. Run `flutter analyze` and `flutter test test/widgets/` plus the
tests of the touched view.
- The scans in `test/lint/` fail on the common UI mistakes and run as part of `flutter test`: an `IconButton` whose
icon is an icon needs a `tooltip`; a field that constructs its own controller, notifier, or focus node is released in
the same file; a public declaration nothing references is dead. `.agents/rules.md` explains each rule and its
allowlist.
## Pitfalls
- Do not introduce a new visual system for one screen.
- Do not manually edit generated localization or provider files.
- Avoid broad layout rewrites unless the requested change requires them.
- Do not mutate provider/domain state merely to smooth a transition. Keep presentation holds local and let real errors
bypass them immediately.
- Do not leave loading animations active when callbacks throw. Test the exception path, not only the successful tap.
## Current Interaction Examples
- `CoreStatusButton` watches `coreStatusProvider` but keeps its 600-millisecond connecting hold locally. Taps are ignored
during the hold or genuine connecting state; disconnected cancels the hold immediately.
- Proxy delay testing writes `0` while pending, the measured delay on success, and `-1` on failure. `DelayTestButton` resets
its animation in `finally`.
- Do not leave loading animations active when callbacks throw.
- Do not manually edit generated localization or provider files.
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/core-platform
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/localization
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/pre-commit-quality-review
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/ui-work
+92 -4
View File
@@ -6,6 +6,7 @@ on:
- '**'
tags:
- 'v*'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -13,19 +14,23 @@ concurrency:
env:
IS_STABLE: ${{ !contains(github.ref, '-') }}
FLUTTER_VERSION: '3.47.1'
FLUTTER_VERSION: '3.47.4'
GO_VERSION: '1.26.4'
NDK_VERSION: r28c
jobs:
dart:
name: Dart
# A private copy pays for its Actions minutes, so it runs the checks only when
# started by hand.
if: &checks github.event_name == 'workflow_dispatch' || !github.event.repository.private
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Test commit message hook
run: bash tool/check_commit_msg_test.sh
@@ -33,6 +38,12 @@ jobs:
- name: Test comment density hook
run: bash tool/check_comment_density_test.sh
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: plugins/rust_api/rust
cache-directories: .dart_tool/hooks_runner
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
@@ -49,9 +60,9 @@ jobs:
- name: Analyze
run: flutter analyze --no-fatal-infos
- name: Skip native build hooks
- name: Skip the Go core build hook
run: |
yq -i '.hooks.user_defines.setup.build_assets = false | .hooks.user_defines.rust_api.build_assets = false' pubspec.yaml
yq -i '.hooks.user_defines.setup.build_assets = false' pubspec.yaml
- name: Verify changelog
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
@@ -65,6 +76,7 @@ jobs:
plugins:
name: Plugins
if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -87,8 +99,75 @@ jobs:
dart analyze
dart test
desktop:
name: Desktop ${{ matrix.platform }}
if: *checks
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux
os: ubuntu-latest
- platform: windows
os: windows-2022
- platform: macos
os: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: core/go.sum
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
key: desktop-${{ matrix.platform }}
workspaces: |
services/helper
plugins/rust_api/rust
cache-directories: .dart_tool/hooks_runner
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install Linux build dependencies
if: matrix.platform == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev libsecret-1-dev
- name: Setup Xcode
if: matrix.platform == 'macos'
run: |
sudo xcode-select -s /Applications/Xcode_26.3.app || sudo xcode-select -s /Applications/Xcode_26.2.app
xcodebuild -version
- name: Enable git long paths (Windows)
if: matrix.platform == 'windows'
run: git config --global core.longpaths true
- name: Install dependencies
run: flutter pub get
# Compiles the desktop runners and the native plugin code that only the
# tagged release matrix built before; the bundle itself is discarded.
- name: Build debug bundle
run: flutter build ${{ matrix.platform }} --debug
go:
name: Go core
if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -140,6 +219,7 @@ jobs:
android:
name: Android unit tests
if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -190,6 +270,7 @@ jobs:
rust:
name: Rust
if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -214,6 +295,7 @@ jobs:
windows-helper-test:
name: Windows helper test
if: *checks
runs-on: windows-2022
steps:
- name: Checkout
@@ -231,7 +313,8 @@ jobs:
run: cargo test --manifest-path services/helper/Cargo.toml --features windows-service
build:
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
# Releases publish where the release links point: the public repository.
if: github.repository == 'chen08209/FlClash' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
needs: [ dart, plugins, go, android, rust, windows-helper-test ]
runs-on: ${{ matrix.os }}
strategy:
@@ -334,6 +417,11 @@ jobs:
flutter --version
flutter pub get
# See .agents/flutter-upgrade.md before bumping FLUTTER_VERSION.
- name: Disable Impeller on Intel macOS
if: matrix.platform == 'macos' && matrix.arch == 'amd64'
run: plutil -replace FLTEnableImpeller -bool NO macos/Runner/Info.plist
- name: Setup
env:
ANDROID_NDK_HOME: ${{ steps.build-ndk.outputs.ndk-path }}
+7
View File
@@ -0,0 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="main.dart (safe mode)" type="FlutterRunConfigurationType" factoryName="Flutter">
<option name="additionalArgs" value="--dart-define-from-file env.json --dart-define=SAFE_MODE=true" />
<option name="filePath" value="$PROJECT_DIR$/lib/main.dart" />
<method v="2" />
</configuration>
</component>
+24
View File
@@ -0,0 +1,24 @@
[
{
"label": "Flutter App (With Env)",
"adapter": "Dart",
"type": "flutter",
"request": "launch",
"program": "lib/main.dart",
"cwd": "$ZED_WORKTREE_ROOT",
"toolArgs": ["--dart-define-from-file", "env.json"],
},
{
"label": "Flutter App (Safe Mode)",
"adapter": "Dart",
"type": "flutter",
"request": "launch",
"program": "lib/main.dart",
"cwd": "$ZED_WORKTREE_ROOT",
"toolArgs": [
"--dart-define-from-file",
"env.json",
"--dart-define=SAFE_MODE=true",
],
},
]
+21
View File
@@ -0,0 +1,21 @@
[
{
"label": "flutter run (With Env)",
"command": "flutter",
"args": ["run", "--dart-define-from-file", "env.json"],
"cwd": "$ZED_WORKTREE_ROOT",
"use_new_terminal": true,
},
{
"label": "flutter run (Safe Mode)",
"command": "flutter",
"args": [
"run",
"--dart-define-from-file",
"env.json",
"--dart-define=SAFE_MODE=true",
],
"cwd": "$ZED_WORKTREE_ROOT",
"use_new_terminal": true,
},
]
+19 -5
View File
@@ -18,6 +18,8 @@ Read these only when the task touches their area:
- [.agents/agent-config.md](.agents/agent-config.md): how to choose between `AGENTS.md`, `.agents`, skills, Codex config,
command rules, and hooks.
- [.agents/worktrees.md](.agents/worktrees.md): worktree hygiene across Claude Code, Codex, and Gemini.
- [.agents/flutter-upgrade.md](.agents/flutter-upgrade.md): SDK-pinned workarounds to recheck before changing the
Flutter version.
- [.agents/skills.md](.agents/skills.md): index of repo-scoped skills in `.agents/skills/`.
## Highest Priority Rules
@@ -25,15 +27,21 @@ 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.
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.
- The request sets the scope. A pre-existing bug, cleanup, or refactor the task did not ask for is a follow-up in the
summary, not part of the change. Edit a file surgically when a rewrite would give the same result, keep scratch checks
out of the repository, and commit tests only where the task asks for them or the neighbouring code already keeps them.
- Comments are allowed and sometimes necessary; excess is the problem. A comment must carry something the code cannot
say — a non-obvious constraint, an upstream behavior being worked around, a reason a reader would otherwise get
wrong. Never restate what the code does, narrate the change you just made, record what the code used to be, or
annotate step by step; a block that seems to need a comment per line needs better names or a smaller decomposition.
Keep density near the repository's own: healthy changes here sit under 4%, and a `comment-density` gate fails a file
Keep density near the repository's own: healthy changes here sit at or under 3.6%, and a `comment-density` gate fails a file
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.
- Start FlClash on the host only as a safe mode build, and end only processes you started, by recorded pid. Never
kill a FlClash instance by name; the user's own instance is not yours to close. See
[.agents/rules.md](.agents/rules.md).
- Use `flutter test`, not `dart test`, because models pull in Flutter types.
- Run code generation after modifying models, providers, or database schema.
- Do not manually edit generated files.
@@ -45,12 +53,18 @@ Read these only when the task touches their area:
requests.
- 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.
- Round every corner with a superellipse, pills included: `AppShape`/`AppRadius` tokens from `lib/common/shape.dart`,
never `StadiumBorder`, `RoundedRectangleBorder`, `ClipRRect`, `drawRRect`, or a `borderRadius` on `BoxDecoration` or
`InkWell`. `test/lint/superellipse_corners_test.dart` enforces it; see the Corner Radius section of
[.agents/rules.md](.agents/rules.md).
- 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.
- For CI parity, verify with `flutter pub get`, `flutter analyze --no-fatal-infos`, and
`flutter test --reporter expanded` when practical.
- Before reporting a change done, run `flutter pub get`, `flutter analyze --no-fatal-infos`, and the `flutter test`
suites that cover it, as CI does. If a toolchain the build hook needs is missing (Go, and on Linux and Windows
cargo), say which check was skipped rather than dropping it silently.
## Repo Skills
Use repo skills from `.agents/skills/` when a task matches their descriptions. Current skills cover localization,
provider tests, UI work, and core/platform changes.
Use repo skills from `.agents/skills/` when a task matches their descriptions; `.claude/skills/` symlinks expose the
same skills to Claude Code. Current skills cover localization, UI work, core/platform changes, and pre-commit
structural quality review.
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
+12
View File
@@ -1,3 +1,4 @@
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
@@ -79,6 +80,10 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
configure<CrashlyticsExtension> {
nativeSymbolUploadEnabled = hasReleaseSigning
}
}
}
@@ -98,6 +103,13 @@ flutter {
source = "../.."
}
// The Crashlytics plugin finalizes R8 with the mapping upload but leaves the native symbol upload to the caller.
if (hasReleaseSigning) {
tasks.matching { it.name == "assembleRelease" || it.name == "bundleRelease" }.configureEach {
finalizedBy("uploadCrashlyticsSymbolFileRelease")
}
}
dependencies {
implementation(project(":service"))
implementation(project(":common"))
@@ -42,9 +42,9 @@ object ServiceController {
binding = null
}
fun invokeMethod(data: String, callback: (String) -> Unit): Result<Unit> = runCatching {
fun invokeMethod(data: String, callback: (ByteArray) -> Unit): Result<Unit> = runCatching {
Core.invokeMethod(data) { result ->
callback(result.orEmpty())
callback(result ?: ByteArray(0))
}
}
@@ -266,12 +266,13 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
@Suppress("DEPRECATION")
private fun updateExcludeFromRecents(value: Boolean?) {
val taskId = activity?.taskId ?: return
val am = getSystemService(GlobalState.application, ActivityManager::class.java)
val task = am?.appTasks?.firstOrNull {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
it.taskInfo.taskId == activity?.taskId
it.taskInfo?.taskId == taskId
} else {
it.taskInfo.id == activity?.taskId
it.taskInfo?.id == taskId
}
}
task?.setExcludeFromRecents(value ?: false)
@@ -59,19 +59,24 @@ val Intent.toPendingIntent: PendingIntent
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
fun Service.startForeground(notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(NotificationManager::class.java)
var channel = manager?.getNotificationChannel(GlobalState.NOTIFICATION_CHANNEL)
if (channel == null) {
channel = NotificationChannel(
GlobalState.NOTIFICATION_CHANNEL,
getString(R.string.service_channel_name),
NotificationManager.IMPORTANCE_LOW,
)
manager?.createNotificationChannel(channel)
}
fun Context.ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = getSystemService(NotificationManager::class.java) ?: return
if (manager.getNotificationChannel(GlobalState.NOTIFICATION_CHANNEL) != null) {
return
}
manager.createNotificationChannel(
NotificationChannel(
GlobalState.NOTIFICATION_CHANNEL,
getString(R.string.service_channel_name),
NotificationManager.IMPORTANCE_LOW,
),
)
}
fun Service.startForeground(notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
GlobalState.NOTIFICATION_ID,
+7
View File
@@ -33,6 +33,13 @@ android {
}
}
// :app strips what it packages; Crashlytics symbolication needs the unstripped libraries to reach its merge.
packaging {
jniLibs {
keepDebugSymbols += "**/*.so"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
+2 -2
View File
@@ -9,8 +9,8 @@ message("CMAKE_BUILD_TYPE ${CMAKE_BUILD_TYPE}")
if (NOT "${CMAKE_BUILD_TYPE}" STREQUAL "Debug")
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
add_compile_options(-O3 -flto -g0 -fno-exceptions -fno-rtti)
add_link_options(-flto -Wl,--gc-sections,--strip-all)
add_compile_options(-O3 -flto -g -fno-exceptions -fno-rtti)
add_link_options(-flto -Wl,--gc-sections)
endif ()
set(LIB_CLASH_INCLUDE_DIR "${CMAKE_SOURCE_DIR}/../cpp/includes/${ANDROID_ABI}")
+5 -4
View File
@@ -70,8 +70,9 @@ Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_suspended(JNIEnv *env, jobject thiz, jboolean suspended) {
suspend(suspended);
Java_com_follow_clash_core_Core_suspended(JNIEnv *env, jobject thiz, jboolean suspended,
jboolean interactive) {
suspend(suspended, interactive);
}
extern "C"
@@ -160,7 +161,7 @@ static void call_invoke_interface_result_impl(void *invoke_interface, const char
return;
}
ATTACH_JNI();
const auto value = new_string(data);
const auto value = new_bytes(data);
env->CallVoidMethod(static_cast<jobject>(invoke_interface),
m_invoke_interface_result,
value);
@@ -190,7 +191,7 @@ JNI_OnLoad(JavaVM *vm, void *) {
m_tun_interface_resolve_package = find_method(c_tun_interface, "resolvePackage",
"(I)Ljava/lang/String;");
m_invoke_interface_result = find_method(c_invoke_interface, "onResult",
"(Ljava/lang/String;)V");
"([B)V");
protect_func = &call_tun_interface_protect_impl;
+14
View File
@@ -93,6 +93,20 @@ jstring jni_new_string(JNIEnv *env, const char *str) {
return result;
}
jbyteArray jni_new_bytes(JNIEnv *env, const char *str) {
const auto length = str == nullptr ? 0 : static_cast<int>(strlen(str));
const auto array = env->NewByteArray(length);
if (jni_clear_exception(env) || array == nullptr) {
return nullptr;
}
env->SetByteArrayRegion(array, 0, length, reinterpret_cast<const jbyte *>(str));
if (jni_clear_exception(env)) {
env->DeleteLocalRef(array);
return nullptr;
}
return array;
}
void jni_attach_thread(scoped_jni *jni) {
if (global_vm->GetEnv(reinterpret_cast<void **>(&jni->env), JNI_VERSION_1_6) == JNI_OK) {
jni->require_release = 0;
+3
View File
@@ -11,6 +11,8 @@ extern void initialize_jni(JavaVM *vm, JNIEnv *env);
extern jstring jni_new_string(JNIEnv *env, const char *str);
extern jbyteArray jni_new_bytes(JNIEnv *env, const char *str);
extern char *jni_get_string(JNIEnv *env, jstring str);
extern void jni_attach_thread(scoped_jni *jni);
@@ -34,3 +36,4 @@ extern bool jni_clear_exception(JNIEnv *env);
#define del_global(obj) env->DeleteGlobalRef(obj)
#define get_string(jstr) jni_get_string(env, jstr)
#define new_string(cstr) jni_new_string(env, cstr)
#define new_bytes(cstr) jni_new_bytes(env, cstr)
@@ -62,6 +62,7 @@ object Core {
external fun suspended(
suspended: Boolean,
interactive: Boolean,
)
private external fun invokeMethod(
@@ -71,12 +72,12 @@ object Core {
fun invokeMethod(
data: String,
cb: (result: String?) -> Unit,
cb: (result: ByteArray?) -> Unit,
) {
invokeMethod(
data,
object : InvokeInterface {
override fun onResult(result: String?) {
override fun onResult(result: ByteArray?) {
cb(result)
}
},
@@ -93,8 +94,8 @@ object Core {
} else {
setEventListener(
object : InvokeInterface {
override fun onResult(result: String?) {
callback(result)
override fun onResult(result: ByteArray?) {
callback(result?.decodeToString())
}
},
)
@@ -110,8 +111,8 @@ object Core {
initParamsString,
setupParamsString,
object : InvokeInterface {
override fun onResult(result: String?) {
callback(result)
override fun onResult(result: ByteArray?) {
callback(result?.decodeToString())
}
},
)
@@ -4,5 +4,5 @@ import androidx.annotation.Keep
@Keep
interface InvokeInterface {
fun onResult(result: String?)
fun onResult(result: ByteArray?)
}
+10 -10
View File
@@ -1,16 +1,16 @@
[versions]
firebaseBom = "34.15.0"
minSdk = "23"
targetSdk = "36"
compileSdk = "36"
firebaseBom = "34.19.0"
minSdk = "24"
targetSdk = "37"
compileSdk = "37"
ndkVersion = "28.2.13676358"
coreKtx = "1.17.0"
annotationJvm = "1.9.1"
coreSplashscreen = "1.0.1"
gson = "2.13.1"
smaliDexlib2 = "3.0.9"
coreKtx = "1.19.1"
annotationJvm = "1.11.0"
coreSplashscreen = "1.2.0"
gson = "2.14.0"
smaliDexlib2 = "3.0.10"
junit = "4.13.2"
kotlinxCoroutines = "1.10.2"
kotlinxCoroutines = "1.11.0"
[libraries]
androidx-core = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
+1 -1
View File
@@ -2,5 +2,5 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-all.zip
@@ -47,13 +47,13 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
networkInfos[network] = NetworkInfo()
updateDns()
scheduleUpdateDns()
}
override fun onLosing(network: Network, maxMsToLive: Int) {
val info = networkInfos[network] ?: return
info.losingUntilMillis = System.currentTimeMillis() + maxMsToLive
updateDns()
scheduleUpdateDns()
if (maxMsToLive > 0) {
mainHandler.postDelayed({
if (networkInfos.containsKey(network)) {
@@ -65,15 +65,24 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
override fun onLost(network: Network) {
networkInfos.remove(network)
updateDns()
scheduleUpdateDns()
}
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
networkInfos[network]?.dnsList = linkProperties.dnsServers
updateDns()
scheduleUpdateDns()
}
}
private val updateDnsTask = Runnable(::updateDns)
// A switch arrives as a burst, and a network that just came up has no DNS
// until its link properties follow; the Core flushes its cache per list.
private fun scheduleUpdateDns() {
mainHandler.removeCallbacks(updateDnsTask)
mainHandler.postDelayed(updateDnsTask, DNS_UPDATE_DELAY_MILLIS)
}
override fun start() {
updateDns()
connectivity?.registerNetworkCallback(request, callback)
@@ -126,6 +135,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
}
private const val DNS_PORT = 53
private const val DNS_UPDATE_DELAY_MILLIS = 200L
private fun InetAddress.asSocketAddressText(port: Int): String = when (this) {
is Inet6Address -> "[$hostAddress]:$port"
@@ -11,6 +11,7 @@ import androidx.core.content.getSystemService
import com.follow.clash.common.Components
import com.follow.clash.common.GlobalState
import com.follow.clash.common.QuickAction
import com.follow.clash.common.ensureNotificationChannel
import com.follow.clash.common.quickIntent
import com.follow.clash.common.receiveBroadcastFlow
import com.follow.clash.common.startForeground
@@ -22,9 +23,9 @@ import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.getSpeedTrafficText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
@@ -50,31 +51,31 @@ internal class NotificationModule(
private val scope: CoroutineScope,
) : ServiceModule {
override fun start() {
service.ensureNotificationChannel()
update(ServiceConfig.notificationParams.value.extended)
scope.launch {
val screenFlow = service.receiveBroadcastFlow {
service.receiveBroadcastFlow {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
}.map { intent ->
intent.action == Intent.ACTION_SCREEN_ON
}.onStart {
emit(isScreenOn())
}.distinctUntilChanged().collectLatest { screenOn ->
if (!screenOn) return@collectLatest
combine(
flow {
while (true) {
delay(1_000)
emit(Unit)
}
},
ServiceConfig.notificationParams,
) { _, params ->
params.extended
}.distinctUntilChanged()
.collect(::update)
}
combine(
flow {
while (true) {
delay(1_000)
emit(Unit)
}
},
ServiceConfig.notificationParams,
screenFlow,
) { _, params, screenOn ->
params.takeIf { screenOn }?.extended
}.filterNotNull()
.distinctUntilChanged()
.collect(::update)
}
}
@@ -102,6 +103,8 @@ internal class NotificationModule(
}
}
private val stopIntent by lazy { QuickAction.STOP.quickIntent.toPendingIntent }
private fun update(params: ExtendedNotificationParams) {
service.startForeground(
with(notificationBuilder) {
@@ -109,11 +112,7 @@ internal class NotificationModule(
setContentText(params.contentText)
clearActions()
if (params.showStopAction) {
addAction(
0,
params.stopText,
QuickAction.STOP.quickIntent.toPendingIntent,
)
addAction(0, params.stopText, stopIntent)
}
build()
},
@@ -22,7 +22,7 @@ internal class SuspendModule(
get() = service.getSystemService<PowerManager>()?.isDeviceIdleMode ?: true
private fun updateSuspension(screenOn: Boolean) {
Core.suspended(!screenOn && isDeviceIdle)
Core.suspended(!screenOn && isDeviceIdle, screenOn)
}
override fun start() {
@@ -42,6 +42,6 @@ internal class SuspendModule(
}
override fun stop() {
Core.suspended(false)
Core.suspended(false, isScreenOn())
}
}
+3 -3
View File
@@ -19,10 +19,10 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
id("com.android.application") version "9.4.1" apply false
id("org.jetbrains.kotlin.android") version "2.4.20" apply false
id("com.google.gms.google-services") version "4.5.0" apply false
id("com.google.firebase.crashlytics") version "3.0.7" apply false
id("com.google.firebase.crashlytics") version "3.0.8" apply false
}
include(":app")
+235 -79
View File
@@ -9,19 +9,32 @@
"profiles": "Profiles",
"tools": "Tools",
"logs": "Logs",
"logsDesc": "Captured log records",
"resources": "Resources",
"resourcesDesc": "Information about external resources",
"trafficUsage": "Traffic usage",
"networkSpeed": "Network speed",
"outboundMode": "Outbound mode",
"networkDetection": "Network detection",
"serviceStatus": "Service status",
"serviceRestricted": "Access restricted",
"serviceAvailable": "Available",
"serviceUnavailable": "Unavailable",
"serviceFailed": "Check failed",
"serviceDisallowedIsp": "Disallowed ISP",
"serviceBlocked": "Blocked",
"serviceUnsupportedRegion": "Region not supported",
"serviceOriginalsOnly": "Originals only",
"serviceComingSoon": "Coming soon",
"servicePending": "Not checked",
"serviceManage": "Manage services",
"serviceCheck": "Check",
"serviceCheckAll": "Check all",
"serviceCheckedAt": "Checked at {time}",
"upload": "Upload",
"download": "Download",
"usedTraffic": "Used traffic",
"totalTraffic": "Total traffic",
"expireTime": "Expiration time",
"nullProfileDesc": "No profiles yet, please add one first",
"nullProfileDesc": "Add a profile to get started",
"settings": "Settings",
"language": "Language",
"defaultText": "Default",
@@ -36,27 +49,22 @@
"themeDesc": "Set dark mode and adjust colors",
"override": "Override",
"allowLan": "Allow LAN",
"allowLanDesc": "Allow proxy access over the LAN",
"tun": "TUN",
"tunDesc": "Only effective in administrator mode",
"minimizeOnExit": "Minimize on exit",
"minimizeOnExitDesc": "Override the default system exit behavior",
"autoLaunch": "Auto launch",
"autoLaunchDesc": "Launch automatically at system startup",
"silentLaunch": "Silent launch",
"silentLaunchDesc": "Start in the background",
"silentLaunchDesc": "Start without showing the window",
"autoRun": "Auto run",
"autoRunDesc": "Run automatically when the app opens",
"logcat": "Logcat",
"logcatDesc": "Disabling hides the log entry point",
"autoCheckUpdate": "Auto check for updates",
"autoCheckUpdateDesc": "Check for updates automatically when the app starts",
"checkCertificate": "Verify TLS certificates",
"checkCertificateDesc": "Reject untrusted certificates. Turning this off exposes subscriptions and backups to man-in-the-middle attacks",
"accessControl": "Access control",
"accessControlDesc": "Control which apps use the proxy",
"application": "Application",
"applicationDesc": "Adjust application settings",
"edit": "Edit",
"confirm": "Confirm",
"update": "Update",
@@ -103,12 +111,14 @@
"show": "Show",
"exit": "Exit",
"systemProxy": "System proxy",
"safeMode": "Safe mode",
"safeModeAppTitle": "{appName} (Safe mode)",
"project": "Project",
"core": "Core",
"tabAnimation": "Tab animation",
"desc": "A multi-platform proxy client based on ClashMeta, simple and easy to use, open-source and ad-free.",
"startVpn": "Starting VPN...",
"stopVpn": "Stopping VPN...",
"startVpn": "Starting VPN…",
"stopVpn": "Stopping VPN…",
"compatible": "Compatibility mode",
"notSelectedTip": "The current proxy group cannot be selected",
"changeProxyFailedTip": "Failed to switch proxy; the previous selection has been restored",
@@ -137,28 +147,26 @@
"country": "Region",
"search": "Search",
"allowBypass": "Allow apps to bypass VPN",
"allowBypassDesc": "When enabled, some apps can bypass the VPN",
"externalController": "External controller",
"externalControllerDesc": "When enabled, the Clash core can be controlled on port 9090",
"ipv6Desc": "When enabled, IPv6 traffic can be received",
"app": "App",
"general": "General",
"systemProxyDesc": "Set the system proxy",
"startupAndBackground": "Startup and background",
"requestsAndUpdates": "Requests and updates",
"logsAndDiagnostics": "Logs and diagnostics",
"userAgent": "User-Agent",
"unifiedDelay": "Unified delay",
"unifiedDelayDesc": "Remove extra delays such as handshakes",
"tcpConcurrent": "TCP concurrent",
"tcpConcurrentDesc": "Allow concurrent TCP connections",
"geodataLoader": "Geo low-memory mode",
"geodataLoaderDesc": "Use the low-memory Geo loader",
"requests": "Requests",
"requestsDesc": "View recent request records",
"recentRequests": "Recent requests",
"findProcessMode": "Find process",
"init": "Init",
"infiniteTime": "Never expires",
"connections": "Connections",
"liveConnections": "Live connections",
"inbound": "Inbound",
"connectionsDesc": "View current connection data",
"intranetIP": "Intranet IP",
"view": "View",
"cut": "Cut",
@@ -182,19 +190,18 @@
"go": "Go",
"externalLink": "External link",
"otherContributors": "Other contributors",
"appIconDesign": "App icon design",
"autoCloseConnections": "Auto close connections",
"autoCloseConnectionsDesc": "Close connections automatically after switching nodes",
"onlyStatisticsProxy": "Only count proxy traffic",
"onlyStatisticsProxyDesc": "When enabled, only proxy traffic is counted",
"showNotificationStopAction": "Stop button in notification",
"showNotificationStopActionDesc": "Show a stop button on the persistent notification. Turn it off if your system keeps the notification expanded because of it",
"pureBlackMode": "Pure black mode",
"sidebarBlur": "Sidebar blur",
"sidebarBlurDesc": "Show the blurred desktop behind the window through the sidebar",
"keepAliveIntervalDesc": "TCP keep-alive interval",
"entries": " entries",
"local": "Local",
"remote": "Remote",
"remoteBackupDesc": "Back up data to WebDAV",
"localBackupDesc": "Back up data locally",
"mode": "Mode",
"time": "Time",
"source": "Source",
@@ -208,70 +215,121 @@
"loose": "Loose",
"profilesSort": "Sort profiles",
"start": "Start",
"runTime": "Run time",
"stop": "Stop",
"dnsDesc": "Update DNS-related settings",
"key": "Key",
"value": "Value",
"hostsDesc": "Append hosts",
"vpnTip": "Changes take effect after restarting the VPN",
"vpnEnableDesc": "Route all system traffic through VpnService automatically",
"options": "Options",
"loopback": "Loopback unlock tool",
"loopbackDesc": "Used for UWP loopback exemption",
"enabled": "Enabled",
"disabled": "Disabled",
"loopback": "UWP loopback exemption",
"providers": "External resources",
"proxyProviders": "Proxy providers",
"ruleProviders": "Rule providers",
"appProxyProviders": "App proxy providers",
"appRuleProviders": "App rule providers",
"behavior": "Behavior",
"format": "Format",
"providerUrlTip": "Only remote providers are supported",
"subscriptionInfo": "Subscription info",
"overrideDns": "Override DNS",
"overrideDnsDesc": "When enabled, the DNS options in the profile are overridden",
"overrideNtp": "Override NTP",
"ntpStatusDesc": "Take the time from an NTP server instead of the system clock",
"server": "Server",
"ntpInterval": "Sync interval (minutes)",
"dialerProxy": "Dialer proxy",
"dialerProxyDesc": "The outbound used to reach the NTP server",
"writeToSystem": "Write to system",
"writeToSystemDesc": "Also set the system clock; Android ignores it",
"overrideEntries": "Override entries",
"addOverrideEntry": "Add override entry",
"discardChanges": "Discard the changes?",
"quickEdit": "Quick edit",
"status": "Status",
"statusDesc": "When disabled, the system DNS is used",
"preferH3Desc": "Prefer HTTP/3 for DoH",
"respectRules": "Respect rules",
"respectRulesDesc": "DNS connections follow rules; requires proxy-server-nameserver",
"respectRulesDesc": "DNS connections follow rules; requires Proxy Server Nameserver",
"dnsMode": "DNS mode",
"fakeipRange": "Fake-IP range",
"fakeipFilter": "Fake-IP filter",
"defaultNameserver": "Default nameserver",
"defaultNameserverDesc": "Used to resolve DNS servers",
"nameserver": "Nameserver",
"nameserverDesc": "Used to resolve domains",
"useHosts": "Use hosts",
"useSystemHosts": "Use system hosts",
"nameserverPolicy": "Nameserver policy",
"nameserverPolicyDesc": "Specify the nameserver policy for matching domains",
"proxyNameserver": "Proxy nameserver",
"proxyNameserverDesc": "Used to resolve proxy node domains",
"fallback": "Fallback",
"fallbackDesc": "Usually an overseas DNS",
"fallbackFilter": "Fallback filter",
"geoipCode": "GeoIP code",
"ipcidr": "IP/CIDR",
"domain": "Domain",
"listenRoutingMark": "Listen routing mark",
"listenRoutingMarkDesc": "Linux only",
"ipv6Timeout": "IPv6 timeout (ms)",
"cacheAlgorithm": "Cache algorithm",
"cacheMaxSize": "Cache size",
"fakeipRange6": "Fake-IP range (IPv6)",
"fakeipFilterMode": "Fake-IP filter mode",
"fakeipFilterModeDesc": "blacklist excludes matches, whitelist fakes only matches, rule matches as rules",
"fakeipTtl": "Fake-IP TTL",
"reset": "Reset",
"actionView": "Show/Hide",
"actionStart": "Start/Stop",
"actionMode": "Switch mode",
"actionProxy": "System proxy",
"actionTun": "TUN",
"actionRuleMode": "Rule mode",
"actionGlobalMode": "Global mode",
"actionDirectMode": "Direct mode",
"actionDelayTest": "Test all delays",
"actionUpdateProfiles": "Update profiles",
"disclaimer": "Disclaimer",
"disclaimerDesc": "This software is intended only for non-commercial uses such as learning and research. Using it for any commercial purpose is strictly prohibited; any commercial activity is unrelated to this software.",
"disclaimerDesc": "Before using FlClash (\"the Software\"), please read this statement carefully and make sure you understand all of it. Tapping \"Agree\" means you have read, understood, and accept every term below. If you do not agree, tap \"Exit\" and stop using the Software.",
"disclaimerSoftwareTitle": "Nature of the software",
"disclaimerSoftwareContent": "The Software is an open-source network proxy client built on the Clash.Meta (mihomo) core. It only provides local tooling such as configuration management, rule-based routing, and traffic forwarding.\n\nThe Software itself does not provide any proxy server, node, subscription, or network access service, and has no partnership, agency, or guarantee relationship with any provider of such services.",
"disclaimerUsageTitle": "Restrictions on use",
"disclaimerUsageContent": "The Software is intended only for non-commercial uses such as learning, exchange, and technical research. Using it for any commercial purpose is strictly prohibited, including but not limited to paid distribution, bundled sales, use as part of a commercial service, or doing business in the name of the Software. Any commercial activity is unrelated to the Software and its developers.\n\nUsing the Software for anything that violates the laws and regulations of your country or region is strictly prohibited, including but not limited to bypassing lawfully imposed network access restrictions, spreading illegal content, launching network attacks, or infringing the lawful rights of others.",
"disclaimerResponsibilityTitle": "Your responsibility",
"disclaimerResponsibilityContent": "You are responsible for making sure that using the Software is lawful where you live, and you alone bear the legal responsibility for everything you do with it and its consequences.\n\nThe subscriptions, nodes, and configurations you import are your own choice. Whether their source is lawful, their content is safe, and their service is reliable is a matter between you and their providers.",
"disclaimerThirdPartyTitle": "Third-party content",
"disclaimerThirdPartyContent": "Subscription links, configuration files, rule sets, scripts, external resources, and external links are all provided by third parties. The developers cannot and do not review or guarantee their legality, accuracy, security, or availability.\n\nAny data leak, financial loss, account ban, or other loss caused by third-party content is to be settled between you and the third party; the developers bear no responsibility for it.",
"disclaimerWarrantyTitle": "No warranty",
"disclaimerWarrantyContent": "The Software is provided \"as is\" and \"as available\", without warranty of any kind, express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, uninterrupted availability, freedom from errors, or freedom from security vulnerabilities.\n\nThe developers do not guarantee that the Software will meet your needs or that it will run without interruption or error.",
"disclaimerLiabilityTitle": "Limitation of liability",
"disclaimerLiabilityContent": "To the maximum extent permitted by applicable law, neither the developers nor any contributor shall be liable for any direct, indirect, incidental, special, punitive, or consequential damages arising from the use of or inability to use the Software, including but not limited to data loss, device damage, network failures, business interruption, lost profits, or any resulting legal dispute, even if advised of the possibility of such damages.",
"disclaimerPrivacyTitle": "Data collection and privacy",
"disclaimerPrivacyContent": "The Software does not collect or upload your subscription URLs, node details, configuration content, visited websites, connection records, traffic content, or logs. This data stays on your device and the developers have no access to it.\n\nThe Software only reaches the network when you use a feature that needs it, such as fetching the subscription URL you provided when updating a profile, or contacting GitHub when checking for updates.\n\nThe desktop versions (Windows, macOS, Linux) include no analytics or crash reporting service. The Android version includes the following two Google Firebase services to improve stability:",
"disclaimerCrashlyticsTitle": "Firebase Crashlytics (crash analytics)",
"disclaimerCrashlyticsContent": "When the app crashes, a crash report is uploaded automatically.\n\nWhat is collected: the crash stack trace and error message, the time of the crash, the app version and build number, the device brand and model, the Android version, screen orientation, free memory and storage, whether the device is rooted, and a random installation ID that is created on install and reset on reinstall.\n\nPurpose: only to locate and fix crashes.\n\nYou can turn it off at any time under \"Tools > General > Crash analytics\".",
"disclaimerAnalyticsTitle": "Firebase Analytics (usage statistics)",
"disclaimerAnalyticsContent": "Basic app usage statistics are collected automatically with Firebase.\n\nWhat is collected: basic events such as first launch, app opens and session length, and app updates; an app instance ID; the device model, OS version, and system language; and an approximate country- or region-level location inferred from the IP address.\n\nPurpose: only to understand the number of active devices, version distribution, and OS compatibility. The developers do not use this data for advertising, do not sell it, and do not link it to your subscriptions or configurations.",
"disclaimerDataProcessingContent": "This data is processed and stored by Google on our behalf, may be transferred to servers outside your country or region (such as in the United States), and is governed by the Google Privacy Policy and the Firebase privacy and security documentation. Crash reports are kept for up to 90 days; statistics are kept under the Firebase default retention policy.",
"disclaimerLicenseTitle": "Open-source license",
"disclaimerLicenseContent": "The Software is open source under the GPL-3.0 license. You may use, modify, and distribute it freely as long as you comply with that license, which requires derivative works to be released under GPL-3.0 as well and the original copyright notices to be kept.\n\nThird-party components in the Software, including the Clash.Meta core, follow their own licenses. The original authors are not responsible for any issue arising from modified or redistributed versions.",
"disclaimerChangesTitle": "Changes to this statement",
"disclaimerChangesContent": "The developers may revise this statement with any release, and the revision takes effect when that release is published. Continuing to use the Software after updating means you accept the revised statement.",
"disclaimerAcceptTitle": "Acceptance",
"disclaimerAcceptContent": "By installing, copying, or using the Software, you are deemed to have read and agreed to this entire statement. If you disagree with any of its terms, stop using and uninstall the Software immediately.",
"disclaimerAndroidOnly": "Android only",
"disclaimerGooglePrivacy": "Google Privacy Policy",
"disclaimerFirebasePrivacy": "Firebase privacy and security",
"agree": "Agree",
"hotkeyManagement": "Hotkey management",
"hotkeyManagementDesc": "Control the app with the keyboard",
"pressKeyboard": "Please press a key",
"inputCorrectHotkey": "Please enter a valid hotkey",
"hotkeyConflict": "Hotkey conflict",
"hotkeyDesc": "Global hotkeys work even while the window is hidden. Tap an action to record its key combination.",
"hotkeyNotSet": "Not set",
"pressKeyboard": "Press a key combination",
"hotkeyNeedsModifier": "Include at least one of {modifiers}",
"hotkeyConflictWith": "Already used by “{action}”. Saving moves it here.",
"hotkeyUnavailable": "Not registered, it may be taken by another app",
"remove": "Remove",
"noHotKey": "No hotkeys yet",
"noNetwork": "No network",
"ipv6InboundDesc": "Allow IPv6 inbound",
"exportLogs": "Export logs",
"exportSuccess": "Export successful",
"iconStyle": "Icon style",
"onlyIcon": "Icon only",
"iconStyleFilled": "Filled",
"iconStylePlain": "Plain",
"iconStyleHidden": "Hidden",
"filter": "Filter",
"hideTimeoutProxies": "Hide timed-out nodes",
"hideTimeoutProxiesDesc": "Leave out nodes whose last delay test timed out",
"stackMode": "Stack mode",
"network": "Network",
"networkDesc": "Adjust network-related settings",
"bypassDomain": "Bypass domains",
"bypassDomainDesc": "Only takes effect while the system proxy is enabled",
"resetTip": "Are you sure you want to reset?",
@@ -284,7 +342,6 @@
"routeModeBypassPrivate": "Bypass private addresses",
"routeModeConfig": "Use config",
"routeAddress": "Route addresses",
"routeAddressDesc": "Configure the listened route addresses",
"interfaceNameMode": "Outbound interface",
"interfaceNameModeClear": "Clear",
"interfaceNameModeFollow": "Follow config",
@@ -293,6 +350,20 @@
"interfaceNameDesc": "Network interface used for outbound connections",
"copyEnvVar": "Copy environment variables",
"memoryInfo": "Memory info",
"total": "Total",
"releaseMemory": "Release memory",
"memoryCoreHeapInuse": "Heap in use",
"memoryCoreHeapIdle": "Heap idle",
"memoryCoreStack": "Goroutine stacks",
"memoryCoreRuntime": "Runtime overhead",
"memoryCoreNotRunning": "Core is not running",
"memoryAppResident": "Resident memory",
"memoryAppShared": "App & shared",
"memoryEstimateDesc": "Estimated from process resident memory; it may differ from what the system reports.",
"memoryEstimateSharedDesc": "The Core runs inside the app process. Its share is estimated from runtime stats, and the rest counts as app and shared memory.",
"memoryReleased": "Memory released",
"memoryReleasedSize": "Released {size}",
"releaseMemoryFailed": "Failed to release memory",
"cancel": "Cancel",
"fileIsUpdate": "The file has been modified. Save the changes?",
"profileHasUpdate": "The profile has been modified. Turn off auto update?",
@@ -305,27 +376,27 @@
"listen": "Listen",
"undo": "Undo",
"redo": "Redo",
"lineWrap": "Word wrap",
"none": "None",
"basicConfig": "Basic configuration",
"basicConfigDesc": "Modify the basic configuration globally",
"advancedConfig": "Advanced configuration",
"advancedConfigDesc": "Provides diverse configuration options",
"advancedConfigDesc": "Network, DNS, added rules, and scripts",
"selectedCountTitle": "{count} selected",
"addRule": "Add rule",
"ruleName": "Rule name",
"content": "Content",
"subRule": "Sub-rule",
"ruleTarget": "Rule target",
"matchTargetTitle": "Match target",
"matchTarget": "MATCH-TARGET",
"followProfile": "Follow profile",
"matchTargetDesc": "Where rules targeting MATCH-TARGET go. Defaults to the target of the final MATCH rule in this profile.",
"selectMatchTarget": "Select MATCH-TARGET",
"sourceIp": "Source IP",
"noResolve": "Don't resolve IP",
"saveChanges": "Save the changes?",
"findProcessModeDesc": "Enabling causes some performance loss",
"tabAnimationDesc": "Only effective in mobile view",
"navigationBarStyle": "Bottom bar",
"floating": "Floating",
"docked": "Docked",
"slide": "Slide",
"fade": "Fade",
"pureBlack": "Pure black",
"colorSchemes": "Color schemes",
"palette": "Palette",
"tonalSpotScheme": "Tonal spot",
@@ -348,6 +419,8 @@
"launchInterruptedTip": "The app exited unexpectedly while it was starting up last time. Automatic setup was skipped for this launch; you can start it manually to retry.",
"clearData": "Clear data",
"textScale": "Text scaling",
"textScalePreview": "Text in the app will look like this",
"followSystem": "Follow system",
"internet": "Internet",
"systemApp": "System apps",
"noNetworkApp": "No-network apps",
@@ -361,7 +434,13 @@
"interval": "Interval",
"existsTip": "{label} already exists",
"maxLengthTip": "{label} must be at most {max} characters",
"multipleValuesTip": "Separate multiple values with commas",
"singleAdd": "Single add",
"batchAdd": "Batch add",
"batchListInputTip": "One item per line, or separated by commas",
"batchMapInputTip": "One entry per line: key, a space, then value",
"batchPreviewTip": "{count} to add, {skipped} skipped as existing",
"lineIssueTip": "Line {line}: {message}",
"singleValueTip": "{label} must be a single item",
"deleteTip": "Are you sure you want to delete this {label}?",
"deleteMultipTip": "Are you sure you want to delete the selected {label}?",
"nullTip": "No {label} yet",
@@ -378,6 +457,7 @@
"import": "Import",
"importFile": "Import from file",
"importUrl": "Import from URL",
"startFromScratch": "Start from scratch",
"autoSetSystemDns": "Auto-set system DNS",
"details": "{label} details",
"creationTime": "Creation time",
@@ -396,7 +476,7 @@
"request": "Request",
"connected": "Connected",
"disconnected": "Disconnected",
"connecting": "Connecting...",
"connecting": "Connecting…",
"restartCoreTip": "Are you sure you want to restart the core?",
"forceRestartCoreTip": "Are you sure you want to force restart the core?",
"dnsHijacking": "DNS hijacking",
@@ -406,20 +486,25 @@
"crashlytics": "Crash analytics",
"crashlyticsTip": "When enabled, crash logs without sensitive information are uploaded automatically when the app crashes",
"appendSystemDns": "Append system DNS",
"appendSystemDnsTip": "Force-append the system DNS to the configuration",
"editRule": "Edit rule",
"overrideMode": "Override mode",
"standardModeDesc": "Standard mode: overrides the basic configuration and offers simple rule additions",
"scriptModeDesc": "Script mode: uses external extension scripts to override the configuration in one click",
"addedRules": "Added rules",
"controlGlobalAddedRules": "Control global added rules",
"quickAdd": "Quick add",
"rulePresetBlockQuic": "Block QUIC",
"rulePresetBlockStun": "Block STUN",
"rulePresetBlockDot": "Block DNS over TLS",
"rulePresetSystemServicesDirect": "Apple and Microsoft direct",
"rulePresetBittorrentDirect": "BitTorrent direct",
"rulePresetLanDirect": "LAN direct",
"overrideScript": "Override script",
"goToConfigureScript": "Go to script configuration",
"editGlobalRules": "Edit global rules",
"externalFetch": "External fetch",
"confirmForceCrashCore": "Are you sure you want to force crash the core?",
"confirmClearAllData": "Are you sure you want to clear all data?",
"loading": "Loading...",
"loading": "Loading…",
"yearsAgo": "{count, plural, =1{1 year ago} other{{count} years ago}}",
"monthsAgo": "{count, plural, =1{1 month ago} other{{count} months ago}}",
"daysAgo": "{count, plural, =1{1 day ago} other{{count} days ago}}",
@@ -435,24 +520,31 @@
"speedStatistics": "Speed statistics",
"resetPageChangesTip": "This page has changes. Are you sure you want to reset?",
"overwriteTypeCustom": "Custom",
"overwriteTypeCustomDesc": "Custom mode: fully customize proxy groups and rules",
"overwriteTypeCustomDesc": "Custom mode: fully customize proxies, proxy groups and rules",
"unknownNetworkError": "Unknown network error",
"restoreException": "Restore error",
"networkException": "Network error, please check your connection and try again",
"backupFromNewerVersion": "This backup comes from a newer version of the app. Update the app before restoring it",
"networkTimeoutError": "The request timed out. Check your network or proxy, then try again",
"networkHostLookupError": "Couldn't resolve the server address. Check that the URL is correct and DNS is working",
"networkConnectionError": "Couldn't connect to the server. Check your network connection or proxy settings",
"networkTlsError": "Secure connection failed. The server's certificate may be invalid, or the connection is being intercepted",
"networkCancelledError": "The request was cancelled",
"networkAccessDeniedError": "The server denied access (HTTP {code}). The link may have expired, or the credentials are wrong",
"networkNotFoundError": "Nothing was found at this address (HTTP {code}). Check that the URL is correct",
"networkRateLimitedError": "Too many requests (HTTP 429). Wait a moment and try again",
"networkServerError": "The server ran into a problem (HTTP {code}). Try again later",
"networkBadResponseError": "The server rejected the request (HTTP {code})",
"networkRequestFailed": "Network request failed: {detail}",
"invalidBackupFile": "Invalid backup file",
"pruneCache": "Prune cache",
"backupAndRestore": "Backup and restore",
"backupAndRestoreDesc": "Sync data via WebDAV or files",
"restore": "Restore",
"restoreSuccess": "Restore successful",
"restoreFromWebDAVDesc": "Restore data from WebDAV",
"restoreFromFileDesc": "Restore data from a file",
"restoreOnlyConfig": "Restore profiles only",
"restoreAllData": "Restore all data",
"addProfile": "Add profile",
"delayTest": "Delay test",
"proxyGroupEmpty": "Proxy group is empty",
"proxyGroupNameEmpty": "Proxy group name cannot be empty",
"proxyGroupNameDuplicate": "Duplicate proxy group name",
"confirmExitWindow": "Are you sure you want to exit the current window?",
"dataChangedSave": "Data changes detected. Save them?",
@@ -464,6 +556,8 @@
"excludeProxyFilter": "Exclude proxy filter",
"excludeType": "Exclude type",
"expectedStatus": "Expected status",
"strategy": "Strategy",
"tolerance": "Tolerance",
"selectProxies": "Select proxies",
"inputProxyGroupName": "Enter the proxy group name",
"helperCorruptTip": "Helper service unavailable; TUN mode cannot be enabled. Reinstall FlClash to restore it.",
@@ -488,7 +582,7 @@
"basicStrategy": "Basic strategies",
"editProxy": "Edit proxy",
"includeAllProxyProviders": "Include all proxy providers",
"includeAllProxyProvidersTip": "When enabled, the imported proxy providers are overridden",
"includeAllProxyProvidersTip": "When enabled, the group takes every proxy provider of this profile: the subscription's own, plus the profiles and app proxy providers any proxy group uses",
"addProxyProviders": "Add proxy providers",
"includeAllProxies": "Include all proxies",
"includeAllProxiesTip": "Imports all proxies outside proxy groups; extra proxy groups can be added below",
@@ -550,17 +644,16 @@
"invalidSubRule": "{subRule} is an invalid SUB_RULE",
"invalidPolicy": "{target} is an invalid policy",
"invalidProxyProvider": "{providerName} is an invalid proxy provider",
"invalidRuleSet": "{ruleSet} is an invalid rule set",
"invalidNetworkContent": "Only tcp or udp is supported",
"invalidRangeContent": "Enter numbers or ranges such as 80 or 8000-9000, separated by /",
"invalidDscpContent": "A DSCP mark cannot exceed 63",
"invalidProxy": "{proxyName} is an invalid proxy",
"proxyGroupDetectedAbnormal": "The current proxy group is abnormal",
"proxyProviderDetectedAbnormal": "The selected proxy providers are abnormal",
"proxyDetectedAbnormal": "The selected proxies are abnormal",
"createProfile": "Create profile",
"locationPermissionRequired": "Location permission required",
"locationPermissionGuide": "1. Open System Settings > Privacy & Security\n2. Choose Location Services\n3. Find and check {appName} in the list\n\nWhen you are done, return to the app to continue. Thank you for your cooperation.",
"prerequisites": "Prerequisites",
"ignoreBatteryOptimization": "Ignore battery optimization",
"batteryOptimizationDesc": "To keep the app running in the background, disable battery optimization for it. Tap to open settings.",
"batteryOptimizationStatusTip": "Due to system limitations, the battery optimization status cannot be read correctly while running",
"locationPermission": "Location permission",
"locationPermissionDesc": "The system requires location permission to read the Wi-Fi name. On Android choose \"Allow all the time\", otherwise the Wi-Fi name cannot be read while the app is in the background.",
"excludeSsids": "Exclude SSIDs",
@@ -573,7 +666,7 @@
"editSsid": "Edit SSID",
"authorized": "Authorized",
"tapToAuthorize": "Tap to authorize",
"suspended": "Suspended...",
"suspended": "Suspended…",
"geoOptions": "Geo options",
"geoAutoUpdate": "Auto update",
"geoAutoUpdateInterval": "Auto-update interval",
@@ -584,7 +677,6 @@
"geoSkipped": "{name} is already up to date",
"geoUpdated": "{name} updated",
"secondsCount": "{count, plural, =1{1 second} other{{count} seconds}}",
"entriesCount": "{count, plural, =1{1 entry} other{{count} entries}}",
"proxiesCount": "{count, plural, =1{1 proxy} other{{count} proxies}}",
"rulesCount": "{count, plural, =1{1 rule} other{{count} rules}}",
"changelogBreaking": "Breaking changes",
@@ -600,7 +692,6 @@
"pinWindow": "Pin window",
"unpinWindow": "Unpin window",
"exitFullScreen": "Exit full screen",
"toggleLabel": "Toggle labels",
"torch": "Flashlight",
"pickFromAlbum": "Choose from album",
"blockConnection": "Block connection",
@@ -611,11 +702,76 @@
"previousMatch": "Previous match",
"nextMatch": "Next match",
"clearSearch": "Clear search",
"noSearchResults": "No matching results",
"addWidget": "Add widget",
"showPassword": "Show password",
"hidePassword": "Hide password",
"authorize": "Authorize",
"installedAppsPermissionRequired": "App list permission required",
"installedAppsPermissionDesc": "This system hides the installed app list until the permission is granted. Authorize it to configure the per-app proxy.",
"installedAppsPermissionDeniedMessage": "The app list permission was denied, so installed apps cannot be listed. Please grant it manually in system settings."
"installedAppsPermissionDeniedMessage": "The app list permission was denied, so installed apps cannot be listed. Please grant it manually in system settings.",
"outboundIp": "Outbound IP",
"ipAddress": "IP address",
"ipQualityLevel": "Level",
"ipQualityGood": "Good",
"ipQualityNormal": "Normal",
"ipQualityRisky": "Risky",
"ipType": "Type",
"ipTypeResidential": "Residential",
"ipTypeMobile": "Mobile network",
"ipTypeBusiness": "Business",
"ipTypeHosting": "Data center",
"ipFlags": "Flags",
"ipFlagProxy": "Proxy",
"ipFlagVpn": "VPN",
"ipFlagTor": "Tor",
"ipFlagAbuser": "Abuse history",
"ipOrganization": "Organization",
"ipAsn": "ASN",
"ipQualitySource": "Answered by",
"ipQualitySources": "Sources",
"ipQualityFailed": "Couldn't determine the IP type",
"ipQualityRetry": "Check again",
"ipSourceNoType": "No type",
"ipSourceRateLimited": "Rate limited",
"ipSourceIpMismatch": "Different outbound IP",
"hideIp": "Hide IP",
"editorUnavailable": "Editor unavailable",
"replace": "Replace",
"replaceAll": "Replace all",
"cameraPermissionRequired": "Camera permission required",
"cameraPermissionDesc": "Allow camera access in system settings to scan QR codes, or choose a QR code image from the album.",
"cameraUnavailable": "Camera unavailable",
"retry": "Retry",
"invalidProfileQrcode": "This QR code doesn't contain a profile link",
"dnsQueries": "DNS queries",
"recordType": "Record type",
"answers": "Answers",
"responseCode": "Response code",
"error": "Error",
"initiator": "Initiator",
"cache": "Cache",
"proxyNode": "Proxy node",
"yes": "Yes",
"no": "No",
"nonTextProviderFile": "This external resource is not a text file",
"addCustomProxy": "Add proxy",
"proxyDefinition": "Full configuration",
"proxyDefinitionNotMap": "The configuration must be a YAML mapping with a name and a type",
"customProxiesEmpty": "No custom proxies, so the profile's own proxies are used",
"overwriteIssueEmptyName": "The name is empty",
"overwriteIssueReservedName": "{name} is a built-in policy name and cannot be used here",
"overwriteIssueDuplicateName": "The name {name} is already used by another proxy or proxy group",
"overwriteIssueCoreRejected": "The core cannot parse this proxy: {message}",
"overwriteIssueMissingProxies": "These proxies or policies do not exist: {names}",
"overwriteIssueMissingProviders": "These proxy providers do not exist: {names}",
"overwriteIssueNoProxySource": "No proxies or proxy providers are selected, so the core rejects this group",
"overwriteIssueGroupLoop": "Proxy groups reference each other in a loop: {path}",
"overwriteIssuesSummary": "{count} items have problems, and applying this override may fail",
"overwriteIssueSubscriptionGroupMissingProxies": "The profile's own proxy groups name proxies that the custom proxies no longer include: {names}",
"overwriteIssueProviderShadowed": "The profile already has a provider named {name}, so the app-level one is not used. Rename it to use it",
"providerInUse": "{label} is still used by the custom proxy groups or rules of {profiles}. Remove it there first",
"providerSourceSubscription": "Subscription",
"providerRenameShadowed": "The subscriptions of {profiles} already have {label}, so those profiles would switch to theirs. Choose another name",
"appProviderShadowed": "The app-level one of this name is not used here"
}
+236 -80
View File
@@ -9,19 +9,32 @@
"profiles": "プロファイル",
"tools": "ツール",
"logs": "ログ",
"logsDesc": "キャプチャしたログの記録",
"resources": "リソース",
"resourcesDesc": "外部リソースの関連情報",
"trafficUsage": "トラフィック統計",
"networkSpeed": "ネットワーク速度",
"outboundMode": "アウトバウンドモード",
"networkDetection": "ネットワーク検出",
"serviceStatus": "サービスの状態",
"serviceRestricted": "アクセス制限",
"serviceAvailable": "利用可能",
"serviceUnavailable": "利用不可",
"serviceFailed": "検出に失敗しました",
"serviceDisallowedIsp": "許可されていない ISP",
"serviceBlocked": "ブロック済み",
"serviceUnsupportedRegion": "対象外の地域",
"serviceOriginalsOnly": "オリジナル作品のみ",
"serviceComingSoon": "近日提供予定",
"servicePending": "未検査",
"serviceManage": "サービスを管理",
"serviceCheck": "検査",
"serviceCheckAll": "すべて検査",
"serviceCheckedAt": "{time} に検査",
"upload": "アップロード",
"download": "ダウンロード",
"usedTraffic": "使用済みトラフィック",
"totalTraffic": "合計トラフィック",
"expireTime": "有効期限",
"nullProfileDesc": "プロファイルがありません。先にプロファイルを追加してください",
"nullProfileDesc": "プロファイルを追加して始めましょう",
"settings": "設定",
"language": "言語",
"defaultText": "デフォルト",
@@ -36,27 +49,22 @@
"themeDesc": "ダークモードの設定と色の調整",
"override": "上書き",
"allowLan": "LANプロキシ",
"allowLanDesc": "LAN経由でのプロキシ利用を許可します",
"tun": "TUN",
"tunDesc": "管理者モードでのみ有効",
"minimizeOnExit": "終了時に最小化",
"minimizeOnExitDesc": "システム標準の終了動作を変更します",
"autoLaunch": "自動起動",
"autoLaunchDesc": "システム起動時に自動的に起動します",
"silentLaunch": "サイレント起動",
"silentLaunchDesc": "バックグラウンドで起動します",
"silentLaunchDesc": "起動時にウィンドウを表示しません",
"autoRun": "自動実行",
"autoRunDesc": "アプリを開いたときに自動的に実行します",
"logcat": "ログキャプチャ",
"logcatDesc": "無効にするとログの入り口が非表示になります",
"autoCheckUpdate": "更新の自動チェック",
"autoCheckUpdateDesc": "アプリ起動時に更新を自動的にチェックします",
"checkCertificate": "TLS証明書を検証",
"checkCertificateDesc": "信頼できない証明書を拒否します。無効にすると、サブスクリプションやバックアップが中間者攻撃にさらされます",
"accessControl": "アクセス制御",
"accessControlDesc": "プロキシを利用するアプリを設定します",
"application": "アプリケーション",
"applicationDesc": "アプリケーション関連の設定を変更します",
"edit": "編集",
"confirm": "OK",
"update": "更新",
@@ -103,12 +111,14 @@
"show": "表示",
"exit": "終了",
"systemProxy": "システムプロキシ",
"safeMode": "セーフモード",
"safeModeAppTitle": "{appName}(セーフモード)",
"project": "プロジェクト",
"core": "コア",
"tabAnimation": "タブアニメーション",
"desc": "ClashMetaベースのマルチプラットフォーム対応プロキシクライアント。シンプルで使いやすく、オープンソースで広告もありません。",
"startVpn": "VPNを起動しています...",
"stopVpn": "VPNを停止しています...",
"startVpn": "VPNを起動しています…",
"stopVpn": "VPNを停止しています…",
"compatible": "互換モード",
"notSelectedTip": "現在のプロキシグループは選択できません",
"changeProxyFailedTip": "プロキシの切り替えに失敗したため、前回の選択に戻しました",
@@ -137,28 +147,26 @@
"country": "地域",
"search": "検索",
"allowBypass": "アプリによるVPNバイパスを許可",
"allowBypassDesc": "有効にすると、一部のアプリがVPNをバイパスできます",
"externalController": "外部コントローラー",
"externalControllerDesc": "有効にすると、ポート9090でClashコアを制御できます",
"ipv6Desc": "有効にすると、IPv6トラフィックを受信できます",
"app": "アプリ",
"general": "一般",
"systemProxyDesc": "システムプロキシを設定します",
"startupAndBackground": "起動とバックグラウンド",
"requestsAndUpdates": "リクエストと更新",
"logsAndDiagnostics": "ログと診断",
"userAgent": "User-Agent",
"unifiedDelay": "統一遅延",
"unifiedDelayDesc": "ハンドシェイクなどの余分な遅延を除きます",
"tcpConcurrent": "TCP同時接続",
"tcpConcurrentDesc": "有効にすると、TCPの同時接続を許可します",
"geodataLoader": "Geo低メモリモード",
"geodataLoaderDesc": "有効にすると、低メモリのGeoローダーを使用します",
"requests": "リクエスト",
"requestsDesc": "最近のリクエスト記録を表示します",
"recentRequests": "最近のリクエスト",
"findProcessMode": "プロセス検出",
"init": "初期化",
"infiniteTime": "無期限",
"connections": "接続",
"liveConnections": "リアルタイム接続",
"inbound": "インバウンド",
"connectionsDesc": "現在の接続データを表示します",
"intranetIP": "イントラネットIP",
"view": "表示",
"cut": "切り取り",
@@ -182,19 +190,18 @@
"go": "開く",
"externalLink": "外部リンク",
"otherContributors": "その他の貢献者",
"appIconDesign": "アプリアイコンのデザイン",
"autoCloseConnections": "接続を自動的に閉じる",
"autoCloseConnectionsDesc": "ノードの切り替え後、接続を自動的に閉じます",
"onlyStatisticsProxy": "プロキシのみ集計",
"onlyStatisticsProxyDesc": "有効にすると、プロキシのトラフィックのみを集計します",
"onlyStatisticsProxy": "プロキシトラフィックのみ集計",
"showNotificationStopAction": "通知に停止ボタンを表示",
"showNotificationStopActionDesc": "常駐通知に停止ボタンを表示します。これが原因で通知が常に展開される場合はオフにしてください",
"pureBlackMode": "ピュアブラックモード",
"sidebarBlur": "サイドバーのぼかし",
"sidebarBlurDesc": "ウィンドウ背後のデスクトップをぼかしてサイドバーに透過します",
"keepAliveIntervalDesc": "TCPキープアライブ間隔",
"entries": " 件",
"local": "ローカル",
"remote": "リモート",
"remoteBackupDesc": "WebDAVにデータをバックアップします",
"localBackupDesc": "ローカルにデータをバックアップします",
"mode": "モード",
"time": "時刻",
"source": "ソース",
@@ -208,70 +215,121 @@
"loose": "ゆったり",
"profilesSort": "プロファイルの並べ替え",
"start": "開始",
"runTime": "起動時間",
"stop": "停止",
"dnsDesc": "DNS関連の設定を更新します",
"key": "キー",
"value": "値",
"hostsDesc": "Hostsを追加します",
"vpnTip": "変更はVPNの再起動後に有効になります",
"vpnEnableDesc": "VpnServiceでシステムの全トラフィックを自動的にルーティングします",
"options": "オプション",
"loopback": "ループバック解除ツール",
"loopbackDesc": "UWPのループバック解除に使用します",
"enabled": "有効",
"disabled": "無効",
"loopback": "UWP ループバック解除",
"providers": "外部リソース",
"proxyProviders": "プロキシプロバイダー",
"ruleProviders": "ルールプロバイダー",
"appProxyProviders": "アプリのプロキシプロバイダー",
"appRuleProviders": "アプリのルールプロバイダー",
"behavior": "動作",
"format": "形式",
"providerUrlTip": "リモートリソースのみ対応しています",
"subscriptionInfo": "サブスクリプション情報",
"overrideDns": "DNSを上書き",
"overrideDnsDesc": "有効にすると、プロファイル内のDNS設定を上書きします",
"overrideNtp": "NTPを上書き",
"ntpStatusDesc": "システムクロックではなくNTPサーバーから時刻を取得します",
"server": "サーバー",
"ntpInterval": "同期間隔(分)",
"dialerProxy": "ダイヤラープロキシ",
"dialerProxyDesc": "NTPサーバーへの接続に使用するアウトバウンド",
"writeToSystem": "システムに書き込む",
"writeToSystemDesc": "システムクロックも設定します。Androidでは無視されます",
"overrideEntries": "上書き項目",
"addOverrideEntry": "上書き項目を追加",
"discardChanges": "変更を破棄しますか?",
"quickEdit": "クイック編集",
"status": "状態",
"statusDesc": "無効にすると、システムDNSを使用します",
"preferH3Desc": "DoHでHTTP/3を優先します",
"respectRules": "ルールに従う",
"respectRulesDesc": "DNS接続がルールに従います。proxy-server-nameserverの設定が必要です",
"respectRulesDesc": "DNS接続がルールに従います。Proxy Server Nameserverの設定が必要です",
"dnsMode": "DNSモード",
"fakeipRange": "Fake-IP範囲",
"fakeipFilter": "Fake-IPフィルター",
"defaultNameserver": "デフォルトネームサーバー",
"defaultNameserverDesc": "DNSサーバーの名前解決に使用します",
"nameserver": "ネームサーバー",
"nameserverDesc": "ドメインの名前解決に使用します",
"useHosts": "Hostsを使用",
"useSystemHosts": "システムのHostsを使用",
"nameserverPolicy": "ネームサーバーポリシー",
"nameserverPolicyDesc": "ドメインごとのネームサーバーポリシーを指定します",
"proxyNameserver": "プロキシネームサーバー",
"proxyNameserverDesc": "プロキシノードのドメイン解決に使用します",
"fallback": "フォールバック",
"fallbackDesc": "通常は国外のDNSを使用します",
"fallbackFilter": "フォールバックフィルター",
"geoipCode": "GeoIPコード",
"ipcidr": "IP/CIDR",
"domain": "ドメイン",
"listenRoutingMark": "リッスンのルーティングマーク",
"listenRoutingMarkDesc": "Linuxのみ",
"ipv6Timeout": "IPv6タイムアウト(ms)",
"cacheAlgorithm": "キャッシュアルゴリズム",
"cacheMaxSize": "キャッシュサイズ",
"fakeipRange6": "Fake-IP範囲(IPv6)",
"fakeipFilterMode": "Fake-IPフィルターモード",
"fakeipFilterModeDesc": "blacklistは一致を除外、whitelistは一致のみ、ruleはルールで判定",
"fakeipTtl": "Fake-IP TTL",
"reset": "リセット",
"actionView": "表示/非表示",
"actionStart": "開始/停止",
"actionMode": "モード切替",
"actionProxy": "システムプロキシ",
"actionTun": "TUN",
"actionRuleMode": "ルールモード",
"actionGlobalMode": "グローバルモード",
"actionDirectMode": "ダイレクトモード",
"actionDelayTest": "すべての遅延をテスト",
"actionUpdateProfiles": "プロファイルを更新",
"disclaimer": "免責事項",
"disclaimerDesc": "本ソフトウェアは、学習・交流や研究などの非商用目的でのみ使用できます。商用目的での使用は固く禁じられています。いかなる商業行為も本ソフトウェアとは一切関係ありません。",
"disclaimerDesc": "FlClash(以下「本ソフトウェア」)をご利用になる前に、本声明の内容をよくお読みになり、十分にご理解ください。「同意する」をタップすると、以下のすべての条項を読み、理解し、承諾したものとみなされます。同意いただけない場合は「終了」をタップし、本ソフトウェアの使用を中止してください。",
"disclaimerSoftwareTitle": "ソフトウェアの性質",
"disclaimerSoftwareContent": "本ソフトウェアは Clash.Meta(mihomo)コアをベースとしたオープンソースのネットワークプロキシクライアントであり、設定管理、ルールによる振り分け、トラフィック転送などのローカルツール機能のみを提供します。\n\n本ソフトウェア自体は、プロキシサーバー、ノード、サブスクリプション、ネットワーク接続サービスを一切提供しておらず、それらのサービス提供者と提携、代理、保証の関係はありません。",
"disclaimerUsageTitle": "使用の制限",
"disclaimerUsageContent": "本ソフトウェアは学習、交流、技術研究などの非商用目的でのみ使用できます。有償配布、抱き合わせ販売、商用サービスの一部としての利用、本ソフトウェアの名義での事業活動など、あらゆる商用目的での使用を固く禁じます。いかなる商業行為も本ソフトウェアおよび開発者とは一切関係ありません。\n\nお住まいの国または地域の法令に違反する目的での使用を固く禁じます。これには、法に基づくネットワークアクセス制限の回避、違法情報の拡散、サイバー攻撃、他者の権利の侵害などが含まれますが、これらに限りません。",
"disclaimerResponsibilityTitle": "利用者の責任",
"disclaimerResponsibilityContent": "お住まいの国または地域で本ソフトウェアの使用が合法であることはご自身で確認する必要があり、本ソフトウェアを使用したすべての行為とその結果について、ご自身が単独で法的責任を負います。\n\nインポートするサブスクリプション、ノード、設定はご自身の判断で選択したものです。その出所の適法性、内容の安全性、サービスの安定性については、利用者と各提供者の間で解決してください。",
"disclaimerThirdPartyTitle": "サードパーティのコンテンツ",
"disclaimerThirdPartyContent": "サブスクリプションリンク、設定ファイル、ルールセット、スクリプト、外部リソース、外部リンクはすべて第三者が提供するものであり、開発者はその適法性、正確性、安全性、可用性を審査または保証することはできず、また行いません。\n\nサードパーティのコンテンツの使用に起因する情報漏えい、財産上の損失、アカウント停止その他の損失は、利用者と第三者の間で解決するものとし、開発者は一切責任を負いません。",
"disclaimerWarrantyTitle": "無保証",
"disclaimerWarrantyContent": "本ソフトウェアは「現状のまま」かつ「提供可能な範囲で」提供され、商品性、特定目的への適合性、非侵害、継続的な可用性、エラーやセキュリティ上の脆弱性がないことの保証を含め、明示または黙示を問わずいかなる保証も伴いません。\n\n開発者は、本ソフトウェアが利用者の要件を満たすこと、また中断やエラーなく動作することを保証しません。",
"disclaimerLiabilityTitle": "責任の制限",
"disclaimerLiabilityContent": "適用法で認められる最大限の範囲において、開発者およびすべての貢献者は、本ソフトウェアの使用または使用不能に起因する直接的、間接的、偶発的、特別、懲罰的または結果的な損害(データの消失、機器の損傷、ネットワーク障害、業務の中断、逸失利益、およびそれに起因する法的紛争を含むがこれらに限らない)について、その可能性を知らされていた場合であっても、一切責任を負いません。",
"disclaimerPrivacyTitle": "データ収集とプライバシー",
"disclaimerPrivacyContent": "本ソフトウェアは、サブスクリプション URL、ノード情報、設定内容、閲覧したウェブサイト、接続記録、通信内容、ログを収集またはアップロードしません。これらのデータは端末内にのみ保存され、開発者がアクセスすることはできません。\n\n本ソフトウェアが外部ネットワークにアクセスするのは、プロファイル更新時に指定されたサブスクリプション URL へアクセスする場合や、アップデート確認時に GitHub へアクセスする場合など、該当する機能を使用したときのみです。\n\nデスクトップ版(Windows、macOS、Linux)には、統計やクラッシュレポートのサービスは一切組み込まれていません。Android 版には、安定性向上のため以下の 2 つの Google Firebase サービスが組み込まれています。",
"disclaimerCrashlyticsTitle": "Firebase Crashlytics(クラッシュ分析)",
"disclaimerCrashlyticsContent": "アプリがクラッシュした際に、クラッシュレポートを自動的に送信します。\n\n収集内容:クラッシュのスタックトレースとエラー情報、発生日時、アプリのバージョンとビルド番号、端末のメーカーとモデル、Android のバージョン、画面の向き、空きメモリと空きストレージ、root 化の有無、およびインストール時に生成され再インストールでリセットされるランダムなインストール ID。\n\n目的:クラッシュの特定と修正のためにのみ使用します。\n\n「ツール > 一般 > クラッシュ分析」からいつでもオフにできます。",
"disclaimerAnalyticsTitle": "Firebase Analytics(利用統計)",
"disclaimerAnalyticsContent": "Firebase により基本的なアプリ利用統計が自動的に収集されます。\n\n収集内容:初回起動、アプリの起動とセッション時間、アプリの更新などの基本イベント、アプリインスタンス ID、端末のモデル、OS のバージョン、システム言語、および IP アドレスから推定される国または地域レベルのおおよその位置情報。\n\n目的:アクティブな端末数、バージョン分布、OS の互換性を把握するためにのみ使用します。開発者はこれらのデータを広告に使用したり、販売したり、サブスクリプションや設定と関連付けたりすることはありません。",
"disclaimerDataProcessingContent": "これらのデータは Google が代わりに処理・保存し、お住まいの国または地域外(米国など)のサーバーに転送される場合があり、Google のプライバシーポリシーおよび Firebase のプライバシーとセキュリティに関する説明に従って取り扱われます。クラッシュレポートは最大 90 日間保存され、統計データは Firebase の既定の保存ポリシーに従って保存されます。",
"disclaimerLicenseTitle": "オープンソースライセンス",
"disclaimerLicenseContent": "本ソフトウェアは GPL-3.0 ライセンスのもとでオープンソースとして公開されています。同ライセンスを遵守する限り、自由に使用、改変、再配布できますが、派生物も GPL-3.0 で公開し、原著作者の著作権表示を保持する必要があります。\n\n本ソフトウェアに含まれるサードパーティのコンポーネント(Clash.Meta コアを含む)は、それぞれのライセンスに従います。改変版や再配布版に起因する問題について、原著作者は責任を負いません。",
"disclaimerChangesTitle": "本声明の変更",
"disclaimerChangesContent": "開発者はバージョンの更新に伴い本声明を変更することがあり、変更内容は新しいバージョンの公開とともに効力を生じます。更新後も本ソフトウェアを引き続き使用した場合、変更後の声明に同意したものとみなされます。",
"disclaimerAcceptTitle": "声明への同意",
"disclaimerAcceptContent": "本ソフトウェアをインストール、複製または使用した時点で、本声明のすべての内容を読み、同意したものとみなされます。いずれかの条項に同意いただけない場合は、直ちに使用を中止し、本ソフトウェアをアンインストールしてください。",
"disclaimerAndroidOnly": "Android のみ",
"disclaimerGooglePrivacy": "Google プライバシーポリシー",
"disclaimerFirebasePrivacy": "Firebase のプライバシーとセキュリティ",
"agree": "同意する",
"hotkeyManagement": "ホットキー管理",
"hotkeyManagementDesc": "キーボードでアプリを操作します",
"pressKeyboard": "キーを押してください",
"inputCorrectHotkey": "正しいホットキーを入力してください",
"hotkeyConflict": "ホットキーが競合しています",
"hotkeyDesc": "グローバルホットキーはウィンドウが非表示でも有効です。アクションをタップしてキーの組み合わせを記録します。",
"hotkeyNotSet": "未設定",
"pressKeyboard": "キーの組み合わせを押してください",
"hotkeyNeedsModifier": "{modifiers} のいずれかを含めてください",
"hotkeyConflictWith": "「{action}」で使用中です。保存するとこちらに移動します。",
"hotkeyUnavailable": "登録できませんでした。他のアプリが使用している可能性があります",
"remove": "削除",
"noHotKey": "ホットキーはまだありません",
"noNetwork": "ネットワークがありません",
"ipv6InboundDesc": "IPv6インバウンドを許可します",
"exportLogs": "ログをエクスポート",
"exportSuccess": "エクスポートが完了しました",
"iconStyle": "アイコンスタイル",
"onlyIcon": "アイコンのみ",
"iconStyleFilled": "背景あり",
"iconStylePlain": "背景なし",
"iconStyleHidden": "非表示",
"filter": "フィルター",
"hideTimeoutProxies": "タイムアウトしたノードを隠す",
"hideTimeoutProxiesDesc": "前回の遅延テストがタイムアウトしたノードを表示しない",
"stackMode": "スタックモード",
"network": "ネットワーク",
"networkDesc": "ネットワーク関連の設定を変更します",
"bypassDomain": "除外ドメイン",
"bypassDomainDesc": "システムプロキシが有効な場合のみ適用されます",
"resetTip": "リセットしてもよろしいですか?",
@@ -284,7 +342,6 @@
"routeModeBypassPrivate": "プライベートアドレスをバイパス",
"routeModeConfig": "設定を使用",
"routeAddress": "ルートアドレス",
"routeAddressDesc": "リッスンするルートアドレスを設定します",
"interfaceNameMode": "アウトバウンドインターフェース",
"interfaceNameModeClear": "クリア",
"interfaceNameModeFollow": "設定に従う",
@@ -293,6 +350,20 @@
"interfaceNameDesc": "アウトバウンド接続に使用するネットワークインターフェース名",
"copyEnvVar": "環境変数をコピー",
"memoryInfo": "メモリ情報",
"total": "合計",
"releaseMemory": "メモリを解放",
"memoryCoreHeapInuse": "使用中のヒープ",
"memoryCoreHeapIdle": "未使用のヒープ",
"memoryCoreStack": "ゴルーチンスタック",
"memoryCoreRuntime": "ランタイムのオーバーヘッド",
"memoryCoreNotRunning": "コアは実行されていません",
"memoryAppResident": "常駐メモリ",
"memoryAppShared": "アプリと共有",
"memoryEstimateDesc": "プロセスの常駐メモリからの推定値で、システムの表示とは異なる場合があります。",
"memoryEstimateSharedDesc": "コアはアプリと同じプロセスで動作します。コア分はランタイム統計から推定し、残りはアプリと共有メモリとして計上します。",
"memoryReleased": "メモリを解放しました",
"memoryReleasedSize": "{size} を解放しました",
"releaseMemoryFailed": "メモリの解放に失敗しました",
"cancel": "キャンセル",
"fileIsUpdate": "ファイルが変更されています。変更を保存しますか?",
"profileHasUpdate": "プロファイルが変更されています。自動更新を無効にしますか?",
@@ -305,27 +376,27 @@
"listen": "リッスン",
"undo": "元に戻す",
"redo": "やり直す",
"lineWrap": "折り返し",
"none": "なし",
"basicConfig": "基本設定",
"basicConfigDesc": "基本設定をグローバルに変更します",
"advancedConfig": "詳細設定",
"advancedConfigDesc": "多彩な設定項目を提供します",
"advancedConfigDesc": "ネットワーク、DNS、追加ルール、スクリプト",
"selectedCountTitle": "{count} 件選択中",
"addRule": "ルールを追加",
"ruleName": "ルール名",
"content": "内容",
"subRule": "サブルール",
"ruleTarget": "ルールターゲット",
"matchTargetTitle": "マッチ先",
"matchTarget": "MATCH-TARGET",
"followProfile": "プロファイルに従う",
"matchTargetDesc": "MATCH-TARGET を対象にしたルールの行き先。既定ではこのプロファイル末尾の MATCH ルールのターゲットを使います。",
"selectMatchTarget": "MATCH-TARGET を選択",
"sourceIp": "送信元IP",
"noResolve": "IPを解決しない",
"saveChanges": "変更を保存しますか?",
"findProcessModeDesc": "有効にすると、パフォーマンスが多少低下します",
"tabAnimationDesc": "モバイル表示でのみ有効です",
"navigationBarStyle": "ボトムバー",
"floating": "フローティング",
"docked": "固定",
"slide": "スライド",
"fade": "フェード",
"pureBlack": "ピュアブラック",
"colorSchemes": "カラースキーム",
"palette": "パレット",
"tonalSpotScheme": "トーナルスポット",
@@ -348,6 +419,8 @@
"launchInterruptedTip": "前回、アプリは起動中に予期せず終了しました。今回の自動セットアップはスキップしました。手動で起動して再試行できます。",
"clearData": "データを消去",
"textScale": "テキストの拡大縮小",
"textScalePreview": "アプリ内の文字はこの大きさで表示されます",
"followSystem": "システムに従う",
"internet": "インターネット",
"systemApp": "システムアプリ",
"noNetworkApp": "ネットワーク不使用アプリ",
@@ -361,7 +434,13 @@
"interval": "間隔",
"existsTip": "{label}はすでに存在します",
"maxLengthTip": "{label}は最大{max}文字です",
"multipleValuesTip": "複数の値はカンマで区切ってください",
"singleAdd": "個別追加",
"batchAdd": "一括追加",
"batchListInputTip": "1行に1項目、またはカンマ区切りで入力してください",
"batchMapInputTip": "1行に1件、キーと値はスペースで区切ってください",
"batchPreviewTip": "{count}件を追加、{skipped}件は既存のためスキップ",
"lineIssueTip": "{line}行目:{message}",
"singleValueTip": "{label}は1項目のみ指定できます",
"deleteTip": "この{label}を削除してもよろしいですか?",
"deleteMultipTip": "選択した{label}を削除してもよろしいですか?",
"nullTip": "{label}はまだありません",
@@ -378,6 +457,7 @@
"import": "インポート",
"importFile": "ファイルからインポート",
"importUrl": "URLからインポート",
"startFromScratch": "最初から作成",
"autoSetSystemDns": "システムDNSを自動設定",
"details": "{label}の詳細",
"creationTime": "作成日時",
@@ -396,7 +476,7 @@
"request": "リクエスト",
"connected": "接続済み",
"disconnected": "切断済み",
"connecting": "接続中...",
"connecting": "接続中…",
"restartCoreTip": "コアを再起動してもよろしいですか?",
"forceRestartCoreTip": "コアを強制再起動してもよろしいですか?",
"dnsHijacking": "DNSハイジャック",
@@ -406,20 +486,25 @@
"crashlytics": "クラッシュ分析",
"crashlyticsTip": "有効にすると、アプリのクラッシュ時に機密情報を含まないクラッシュログを自動的にアップロードします",
"appendSystemDns": "システムDNSを追加",
"appendSystemDnsTip": "設定にシステムDNSを強制的に追加します",
"editRule": "ルールを編集",
"overrideMode": "上書きモード",
"standardModeDesc": "標準モード:基本設定を上書きし、シンプルなルール追加機能を提供します",
"scriptModeDesc": "スクリプトモード:外部の拡張スクリプトを使用し、ワンクリックで設定を上書きします",
"addedRules": "追加ルール",
"controlGlobalAddedRules": "グローバル追加ルールを管理",
"quickAdd": "クイック追加",
"rulePresetBlockQuic": "QUIC をブロック",
"rulePresetBlockStun": "STUN をブロック",
"rulePresetBlockDot": "DNS over TLS をブロック",
"rulePresetSystemServicesDirect": "Apple と Microsoft に直接接続",
"rulePresetBittorrentDirect": "BitTorrent を直接接続",
"rulePresetLanDirect": "LAN 直接接続",
"overrideScript": "上書きスクリプト",
"goToConfigureScript": "スクリプト設定へ移動",
"editGlobalRules": "グローバルルールを編集",
"externalFetch": "外部取得",
"confirmForceCrashCore": "コアを強制クラッシュさせてもよろしいですか?",
"confirmClearAllData": "すべてのデータを消去してもよろしいですか?",
"loading": "読み込み中...",
"loading": "読み込み中…",
"yearsAgo": "{count} 年前",
"monthsAgo": "{count} か月前",
"daysAgo": "{count} 日前",
@@ -435,24 +520,31 @@
"speedStatistics": "速度統計",
"resetPageChangesTip": "このページには変更があります。リセットしてもよろしいですか?",
"overwriteTypeCustom": "カスタム",
"overwriteTypeCustomDesc": "カスタムモード:プロキシグループとルールを完全にカスタマイズできます",
"overwriteTypeCustomDesc": "カスタムモード:プロキシ、プロキシグループ、ルールを完全にカスタマイズできます",
"unknownNetworkError": "不明なネットワークエラー",
"restoreException": "復元エラー",
"networkException": "ネットワークエラーです。接続を確認してから再試行してください",
"backupFromNewerVersion": "このバックアップは新しいバージョンのアプリで作成されています。アプリを更新してから復元してください",
"networkTimeoutError": "リクエストがタイムアウトしました。ネットワークまたはプロキシを確認してから再試行してください",
"networkHostLookupError": "サーバーのアドレスを解決できませんでした。URL が正しいこと、DNS が使えることを確認してください",
"networkConnectionError": "サーバーに接続できませんでした。ネットワーク接続またはプロキシ設定を確認してください",
"networkTlsError": "安全な接続に失敗しました。サーバー証明書が無効か、接続が傍受されている可能性があります",
"networkCancelledError": "リクエストはキャンセルされました",
"networkAccessDeniedError": "サーバーがアクセスを拒否しました(HTTP {code})。リンクの期限切れか、認証情報が誤っている可能性があります",
"networkNotFoundError": "このアドレスには何も見つかりませんでした(HTTP {code})。URL が正しいか確認してください",
"networkRateLimitedError": "リクエストが多すぎます(HTTP 429)。しばらく待ってから再試行してください",
"networkServerError": "サーバーで問題が発生しました(HTTP {code})。しばらくしてから再試行してください",
"networkBadResponseError": "サーバーがリクエストを拒否しました(HTTP {code})",
"networkRequestFailed": "ネットワークリクエストに失敗しました:{detail}",
"invalidBackupFile": "無効なバックアップファイル",
"pruneCache": "キャッシュを整理",
"backupAndRestore": "バックアップと復元",
"backupAndRestoreDesc": "WebDAVまたはファイルでデータを同期します",
"restore": "復元",
"restoreSuccess": "復元が完了しました",
"restoreFromWebDAVDesc": "WebDAVからデータを復元します",
"restoreFromFileDesc": "ファイルからデータを復元します",
"restoreOnlyConfig": "プロファイルのみ復元",
"restoreAllData": "すべてのデータを復元",
"addProfile": "プロファイルを追加",
"delayTest": "遅延テスト",
"proxyGroupEmpty": "プロキシグループが空です",
"proxyGroupNameEmpty": "プロキシグループ名は空にできません",
"proxyGroupNameDuplicate": "プロキシグループ名が重複しています",
"confirmExitWindow": "現在のウィンドウを閉じてもよろしいですか?",
"dataChangedSave": "データの変更を検出しました。保存しますか?",
@@ -464,6 +556,8 @@
"excludeProxyFilter": "除外ノードフィルター",
"excludeType": "除外タイプ",
"expectedStatus": "期待するステータス",
"strategy": "戦略",
"tolerance": "許容値",
"selectProxies": "プロキシを選択",
"inputProxyGroupName": "プロキシグループ名を入力してください",
"helperCorruptTip": "Helper サービスが利用できないため、TUN モードを有効にできません。FlClash を再インストールしてください。",
@@ -488,7 +582,7 @@
"basicStrategy": "基本ポリシー",
"editProxy": "プロキシを編集",
"includeAllProxyProviders": "すべてのプロキシプロバイダーを含める",
"includeAllProxyProvidersTip": "有効にすると、取り込んだプロキシプロバイダーを上書きします",
"includeAllProxyProvidersTip": "有効にすると、このプロファイルのすべてのプロキシプロバイダーを含めます。サブスクリプション自身のものに加え、いずれかのプロキシグループが使うプロファイルとアプリのプロキシプロバイダーも含みます",
"addProxyProviders": "プロキシプロバイダーを追加",
"includeAllProxies": "すべてのプロキシを含める",
"includeAllProxiesTip": "プロキシグループに属さないすべてのプロキシを取り込みます。下でプロキシグループを追加できます",
@@ -550,17 +644,16 @@
"invalidSubRule": "{subRule} は無効な SUB_RULE です",
"invalidPolicy": "{target} は無効なポリシーです",
"invalidProxyProvider": "{providerName} は無効なプロキシプロバイダーです",
"invalidRuleSet": "{ruleSet} は無効なルールセットです",
"invalidNetworkContent": "tcp または udp のみ対応しています",
"invalidRangeContent": "80 や 8000-9000 のような数値または範囲を / 区切りで入力してください",
"invalidDscpContent": "DSCP マークは 63 を超えられません",
"invalidProxy": "{proxyName} は無効なプロキシです",
"proxyGroupDetectedAbnormal": "現在のプロキシグループに異常が見つかりました",
"proxyProviderDetectedAbnormal": "選択したプロキシプロバイダーに異常が見つかりました",
"proxyDetectedAbnormal": "選択したプロキシに異常が見つかりました",
"createProfile": "プロファイルを作成",
"locationPermissionRequired": "位置情報の権限が必要です",
"locationPermissionGuide": "1. システム設定 > プライバシーとセキュリティ を開く\n2. 位置情報サービス を選択\n3. リストで {appName} を見つけてチェックを入れる\n\n設定が完了したらアプリに戻ると、通常どおり使用できます。ご協力ありがとうございます。",
"prerequisites": "前提条件",
"ignoreBatteryOptimization": "電池の最適化を無視",
"batteryOptimizationDesc": "バックグラウンドでの動作を維持するため、このアプリの電池の最適化を無効にしてください。タップすると設定を開きます。",
"batteryOptimizationStatusTip": "システムの制限により、実行中は電池の最適化の状態を正しく取得できません",
"locationPermission": "位置情報の権限",
"locationPermissionDesc": "システムの要件により、Wi-Fi 名の取得には位置情報の権限が必要です。Android では「常に許可」を選択してください。そうしないと、アプリがバックグラウンドにあるときに Wi-Fi 名を取得できません。",
"excludeSsids": "除外SSID",
@@ -573,7 +666,7 @@
"editSsid": "SSIDを編集",
"authorized": "許可済み",
"tapToAuthorize": "タップして許可",
"suspended": "一時停止中...",
"suspended": "一時停止中…",
"geoOptions": "Geoオプション",
"geoAutoUpdate": "自動更新",
"geoAutoUpdateInterval": "自動更新間隔",
@@ -584,7 +677,6 @@
"geoSkipped": "{name} はすでに最新です",
"geoUpdated": "{name} を更新しました",
"secondsCount": "{count} 秒",
"entriesCount": "{count} 件",
"proxiesCount": "プロキシ {count} 件",
"rulesCount": "ルール {count} 件",
"changelogBreaking": "破壊的変更",
@@ -600,7 +692,6 @@
"pinWindow": "最前面に固定",
"unpinWindow": "固定を解除",
"exitFullScreen": "全画面表示を終了",
"toggleLabel": "ラベルを切り替え",
"torch": "ライト",
"pickFromAlbum": "アルバムから選択",
"blockConnection": "接続をブロック",
@@ -611,11 +702,76 @@
"previousMatch": "前の一致",
"nextMatch": "次の一致",
"clearSearch": "検索をクリア",
"noSearchResults": "一致する結果はありません",
"addWidget": "ウィジェットを追加",
"showPassword": "パスワードを表示",
"hidePassword": "パスワードを隠す",
"authorize": "許可",
"installedAppsPermissionRequired": "アプリ一覧の権限が必要です",
"installedAppsPermissionDesc": "このシステムでは、許可するまでインストール済みアプリの一覧が提供されません。許可すると、アプリごとのプロキシを設定できます。",
"installedAppsPermissionDeniedMessage": "アプリ一覧の権限が拒否されたため、インストール済みアプリを取得できません。システム設定から手動で許可してください。"
"installedAppsPermissionDeniedMessage": "アプリ一覧の権限が拒否されたため、インストール済みアプリを取得できません。システム設定から手動で許可してください。",
"outboundIp": "アウトバウンド IP",
"ipAddress": "IP アドレス",
"ipQualityLevel": "レベル",
"ipQualityGood": "良好",
"ipQualityNormal": "普通",
"ipQualityRisky": "リスクあり",
"ipType": "タイプ",
"ipTypeResidential": "住宅",
"ipTypeMobile": "モバイル回線",
"ipTypeBusiness": "ビジネス",
"ipTypeHosting": "データセンター",
"ipFlags": "検出",
"ipFlagProxy": "プロキシ",
"ipFlagVpn": "VPN",
"ipFlagTor": "Tor",
"ipFlagAbuser": "不正利用の履歴",
"ipOrganization": "組織",
"ipAsn": "ASN",
"ipQualitySource": "採用したソース",
"ipQualitySources": "各ソース",
"ipQualityFailed": "IP タイプを判定できませんでした",
"ipQualityRetry": "再確認",
"ipSourceNoType": "判定不可",
"ipSourceRateLimited": "レート制限",
"ipSourceIpMismatch": "アウトバウンド IP が不一致",
"hideIp": "IP を隠す",
"editorUnavailable": "エディターを利用できません",
"replace": "置換",
"replaceAll": "すべて置換",
"cameraPermissionRequired": "カメラの権限が必要です",
"cameraPermissionDesc": "QRコードをスキャンするには、システム設定でカメラへのアクセスを許可するか、アルバムからQRコード画像を選択してください。",
"cameraUnavailable": "カメラを使用できません",
"retry": "再試行",
"invalidProfileQrcode": "このQRコードにはプロファイルのリンクが含まれていません",
"dnsQueries": "DNSクエリ",
"recordType": "レコードタイプ",
"answers": "応答",
"responseCode": "応答コード",
"error": "エラー",
"initiator": "発信元",
"cache": "キャッシュ",
"proxyNode": "プロキシノード",
"yes": "はい",
"no": "いいえ",
"nonTextProviderFile": "この外部リソースはテキストファイルではありません",
"addCustomProxy": "プロキシを追加",
"proxyDefinition": "完全な設定",
"proxyDefinitionNotMap": "設定は name と type を含む YAML マッピングである必要があります",
"customProxiesEmpty": "カスタムプロキシがないため、プロファイル自身のプロキシを使用します",
"overwriteIssueEmptyName": "名前が空です",
"overwriteIssueReservedName": "{name} は組み込みポリシー名のため使用できません",
"overwriteIssueDuplicateName": "名前 {name} は他のプロキシまたはプロキシグループで使用されています",
"overwriteIssueCoreRejected": "コアがこのプロキシを解析できません:{message}",
"overwriteIssueMissingProxies": "次のプロキシまたはポリシーは存在しません:{names}",
"overwriteIssueMissingProviders": "次のプロキシプロバイダーは存在しません:{names}",
"overwriteIssueNoProxySource": "プロキシもプロキシプロバイダーも選択されていないため、コアはこのグループを拒否します",
"overwriteIssueGroupLoop": "プロキシグループが循環参照しています:{path}",
"overwriteIssuesSummary": "{count} 件に問題があり、上書きの適用に失敗する可能性があります",
"overwriteIssueSubscriptionGroupMissingProxies": "プロファイル自身のプロキシグループが、カスタムプロキシに含まれないプロキシを参照しています:{names}",
"overwriteIssueProviderShadowed": "プロファイルに {name} という名前のプロバイダーが既にあるため、同名のアプリレベルのプロバイダーは使用されません。名前を変更してください",
"providerInUse": "{label} は {profiles} のカスタムプロキシグループまたはルールでまだ使用されています。先にそこから外してください",
"providerSourceSubscription": "サブスクリプション",
"providerRenameShadowed": "{profiles} のサブスクリプションには既に {label} があるため、名前を変えるとそちらが使われます。別の名前にしてください",
"appProviderShadowed": "同名のアプリ側の項目はここでは使われません"
}
+236 -80
View File
@@ -9,19 +9,32 @@
"profiles": "Профили",
"tools": "Инструменты",
"logs": "Логи",
"logsDesc": "Записи захваченных логов",
"resources": "Ресурсы",
"resourcesDesc": "Сведения о внешних ресурсах",
"trafficUsage": "Статистика трафика",
"networkSpeed": "Скорость сети",
"outboundMode": "Режим исходящего трафика",
"networkDetection": "Проверка сети",
"serviceStatus": "Состояние сервисов",
"serviceRestricted": "Доступ ограничен",
"serviceAvailable": "Доступен",
"serviceUnavailable": "Недоступен",
"serviceFailed": "Ошибка проверки",
"serviceDisallowedIsp": "Недопустимый провайдер",
"serviceBlocked": "Заблокировано",
"serviceUnsupportedRegion": "Регион не поддерживается",
"serviceOriginalsOnly": "Только оригиналы",
"serviceComingSoon": "Скоро появится",
"servicePending": "Не проверено",
"serviceManage": "Управление сервисами",
"serviceCheck": "Проверить",
"serviceCheckAll": "Проверить все",
"serviceCheckedAt": "Проверено в {time}",
"upload": "Отдача",
"download": "Загрузка",
"usedTraffic": "Использованный трафик",
"totalTraffic": "Общий трафик",
"expireTime": "Срок действия",
"nullProfileDesc": "Профилей пока нет. Сначала добавьте профиль",
"nullProfileDesc": "Добавьте профиль, чтобы начать",
"settings": "Настройки",
"language": "Язык",
"defaultText": "По умолчанию",
@@ -36,27 +49,22 @@
"themeDesc": "Тёмный режим и настройка цветов",
"override": "Переопределение",
"allowLan": "Разрешить LAN",
"allowLanDesc": "Разрешить доступ к прокси из локальной сети",
"tun": "TUN",
"tunDesc": "Работает только в режиме администратора",
"minimizeOnExit": "Сворачивать при выходе",
"minimizeOnExitDesc": "Изменяет стандартное поведение при выходе",
"autoLaunch": "Автозапуск",
"autoLaunchDesc": "Запускаться автоматически при старте системы",
"silentLaunch": "Тихий запуск",
"silentLaunchDesc": "Запускаться в фоновом режиме",
"silentLaunchDesc": "Запускаться без показа окна",
"autoRun": "Автовключение",
"autoRunDesc": "Включаться автоматически при открытии приложения",
"logcat": "Захват логов",
"logcatDesc": "При отключении раздел логов будет скрыт",
"autoCheckUpdate": "Автопроверка обновлений",
"autoCheckUpdateDesc": "Автоматически проверять обновления при запуске приложения",
"checkCertificate": "Проверять TLS-сертификаты",
"checkCertificateDesc": "Отклонять недоверенные сертификаты. Отключение подвергает подписки и резервные копии атаке «человек посередине»",
"accessControl": "Контроль доступа",
"accessControlDesc": "Выбор приложений, использующих прокси",
"application": "Приложение",
"applicationDesc": "Настройки, связанные с приложением",
"edit": "Редактировать",
"confirm": "Подтвердить",
"update": "Обновить",
@@ -103,12 +111,14 @@
"show": "Показать",
"exit": "Выход",
"systemProxy": "Системный прокси",
"safeMode": "Безопасный режим",
"safeModeAppTitle": "{appName} (Безопасный режим)",
"project": "Проект",
"core": "Ядро",
"tabAnimation": "Анимация вкладок",
"desc": "Многоплатформенный прокси-клиент на основе ClashMeta: простой и удобный, с открытым исходным кодом и без рекламы.",
"startVpn": "Запуск VPN...",
"stopVpn": "Остановка VPN...",
"startVpn": "Запуск VPN…",
"stopVpn": "Остановка VPN…",
"compatible": "Режим совместимости",
"notSelectedTip": "Текущую группу прокси нельзя выбрать",
"changeProxyFailedTip": "Не удалось переключить прокси; восстановлен предыдущий выбор",
@@ -137,28 +147,26 @@
"country": "Регион",
"search": "Поиск",
"allowBypass": "Разрешить приложениям обходить VPN",
"allowBypassDesc": "При включении некоторые приложения смогут обходить VPN",
"externalController": "Внешний контроллер",
"externalControllerDesc": "При включении ядром Clash можно управлять через порт 9090",
"ipv6Desc": "При включении можно принимать трафик IPv6",
"app": "Приложение",
"general": "Общие",
"systemProxyDesc": "Настроить системный прокси",
"startupAndBackground": "Запуск и фоновая работа",
"requestsAndUpdates": "Запросы и обновления",
"logsAndDiagnostics": "Логи и диагностика",
"userAgent": "User-Agent",
"unifiedDelay": "Единая задержка",
"unifiedDelayDesc": "Убирает лишние задержки, например рукопожатие",
"tcpConcurrent": "Параллельный TCP",
"tcpConcurrentDesc": "При включении разрешает параллельные TCP-подключения",
"geodataLoader": "Geo: экономия памяти",
"geodataLoaderDesc": "При включении используется Geo-загрузчик с низким потреблением памяти",
"requests": "Запросы",
"requestsDesc": "Просмотр последних запросов",
"recentRequests": "Последние запросы",
"findProcessMode": "Поиск процесса",
"init": "Инициализация",
"infiniteTime": "Бессрочно",
"connections": "Соединения",
"liveConnections": "Активные соединения",
"inbound": "Входящие",
"connectionsDesc": "Просмотр данных о текущих соединениях",
"intranetIP": "Внутренний IP",
"view": "Просмотр",
"cut": "Вырезать",
@@ -182,19 +190,18 @@
"go": "Перейти",
"externalLink": "Внешняя ссылка",
"otherContributors": "Другие участники",
"appIconDesign": "Дизайн значка приложения",
"autoCloseConnections": "Автозакрытие соединений",
"autoCloseConnectionsDesc": "Автоматически закрывать соединения после смены узла",
"onlyStatisticsProxy": "Учитывать только прокси",
"onlyStatisticsProxyDesc": "При включении учитывается только трафик через прокси",
"onlyStatisticsProxy": "Учитывать только трафик прокси",
"showNotificationStopAction": "Кнопка остановки в уведомлении",
"showNotificationStopActionDesc": "Показывать кнопку остановки в постоянном уведомлении. Отключите, если из-за неё система всегда разворачивает уведомление",
"pureBlackMode": "Чисто чёрный режим",
"sidebarBlur": "Размытие боковой панели",
"sidebarBlurDesc": "Показывать сквозь боковую панель размытый рабочий стол за окном",
"keepAliveIntervalDesc": "Интервал TCP keep-alive",
"entries": " записей",
"local": "Локально",
"remote": "Удалённо",
"remoteBackupDesc": "Резервное копирование данных в WebDAV",
"localBackupDesc": "Резервное копирование данных локально",
"mode": "Режим",
"time": "Время",
"source": "Источник",
@@ -208,70 +215,121 @@
"loose": "Свободный",
"profilesSort": "Сортировка профилей",
"start": "Старт",
"runTime": "Время работы",
"stop": "Стоп",
"dnsDesc": "Настройки, связанные с DNS",
"key": "Ключ",
"value": "Значение",
"hostsDesc": "Добавить записи hosts",
"vpnTip": "Изменения вступят в силу после перезапуска VPN",
"vpnEnableDesc": "Автоматически направляет весь системный трафик через VpnService",
"options": "Опции",
"loopback": "Инструмент разблокировки loopback",
"loopbackDesc": "Для снятия ограничения loopback у UWP-приложений",
"enabled": "Включено",
"disabled": "Выключено",
"loopback": "Снятие ограничения loopback для UWP",
"providers": "Внешние ресурсы",
"proxyProviders": "Провайдеры прокси",
"ruleProviders": "Провайдеры правил",
"appProxyProviders": "Прокси-провайдеры приложения",
"appRuleProviders": "Провайдеры правил приложения",
"behavior": "Поведение",
"format": "Формат",
"providerUrlTip": "Поддерживаются только удалённые ресурсы",
"subscriptionInfo": "Информация о подписке",
"overrideDns": "Переопределить DNS",
"overrideDnsDesc": "При включении настройки DNS профиля переопределяются",
"overrideNtp": "Переопределить NTP",
"ntpStatusDesc": "Брать время с NTP-сервера, а не из системных часов",
"server": "Сервер",
"ntpInterval": "Интервал синхронизации (минуты)",
"dialerProxy": "Прокси для подключения",
"dialerProxyDesc": "Исход, через который идёт обращение к NTP-серверу",
"writeToSystem": "Записывать в систему",
"writeToSystemDesc": "Также устанавливать системные часы; Android это игнорирует",
"overrideEntries": "Переопределяемые параметры",
"addOverrideEntry": "Добавить параметр",
"discardChanges": "Отменить изменения?",
"quickEdit": "Быстрое редактирование",
"status": "Статус",
"statusDesc": "При отключении используется системный DNS",
"preferH3Desc": "Предпочитать HTTP/3 для DoH",
"respectRules": "Соблюдать правила",
"respectRulesDesc": "DNS-соединения следуют правилам; требуется настроить proxy-server-nameserver",
"respectRulesDesc": "DNS-соединения следуют правилам; требуется Proxy Server Nameserver",
"dnsMode": "Режим DNS",
"fakeipRange": "Диапазон Fake-IP",
"fakeipFilter": "Фильтр Fake-IP",
"defaultNameserver": "DNS-сервер по умолчанию",
"defaultNameserverDesc": "Используется для разрешения адресов DNS-серверов",
"nameserver": "DNS-сервер",
"nameserverDesc": "Используется для разрешения доменов",
"useHosts": "Использовать hosts",
"useSystemHosts": "Использовать системный hosts",
"nameserverPolicy": "Политика DNS-серверов",
"nameserverPolicyDesc": "Задать политику DNS-серверов для доменов",
"proxyNameserver": "DNS-сервер для прокси",
"proxyNameserverDesc": "Используется для разрешения доменов прокси-узлов",
"fallback": "Fallback",
"fallbackDesc": "Обычно зарубежный DNS",
"fallbackFilter": "Фильтр fallback",
"geoipCode": "Код GeoIP",
"ipcidr": "IP/CIDR",
"domain": "Домен",
"listenRoutingMark": "Метка маршрутизации",
"listenRoutingMarkDesc": "Только Linux",
"ipv6Timeout": "Тайм-аут IPv6 (мс)",
"cacheAlgorithm": "Алгоритм кэша",
"cacheMaxSize": "Размер кэша",
"fakeipRange6": "Диапазон Fake-IP (IPv6)",
"fakeipFilterMode": "Режим фильтра Fake-IP",
"fakeipFilterModeDesc": "blacklist исключает совпадения, whitelist — только их, rule — по правилам",
"fakeipTtl": "TTL Fake-IP",
"reset": "Сброс",
"actionView": "Показать/Скрыть",
"actionStart": "Старт/Стоп",
"actionMode": "Переключить режим",
"actionProxy": "Системный прокси",
"actionTun": "TUN",
"actionRuleMode": "Режим правил",
"actionGlobalMode": "Глобальный режим",
"actionDirectMode": "Прямой режим",
"actionDelayTest": "Проверить все задержки",
"actionUpdateProfiles": "Обновить профили",
"disclaimer": "Отказ от ответственности",
"disclaimerDesc": "Это программное обеспечение предназначено только для некоммерческого использования: обучения, обмена опытом и научных исследований. Коммерческое использование строго запрещено; любая коммерческая деятельность не имеет отношения к этому программному обеспечению.",
"disclaimerDesc": "Перед использованием FlClash (далее — «Программа») внимательно прочитайте это заявление и убедитесь, что понимаете его полностью. Нажимая «Согласен», вы подтверждаете, что прочитали, поняли и принимаете все приведённые ниже условия. Если вы не согласны, нажмите «Выход» и прекратите использование Программы.",
"disclaimerSoftwareTitle": "Характер программы",
"disclaimerSoftwareContent": "Программа — это клиент сетевого прокси с открытым исходным кодом на основе ядра Clash.Meta (mihomo). Она предоставляет только локальные инструменты: управление конфигурациями, маршрутизацию по правилам и пересылку трафика.\n\nСама Программа не предоставляет прокси-серверы, узлы, подписки или услуги доступа к сети и не состоит в партнёрских, агентских или гарантийных отношениях с поставщиками таких услуг.",
"disclaimerUsageTitle": "Ограничения использования",
"disclaimerUsageContent": "Программа предназначена только для некоммерческого использования: обучения, обмена опытом и технических исследований. Любое коммерческое использование строго запрещено, включая, помимо прочего, платное распространение, продажу в комплекте, использование в составе коммерческого сервиса или ведение деятельности от имени Программы. Любая коммерческая деятельность не имеет отношения к Программе и её разработчикам.\n\nСтрого запрещено использовать Программу для действий, нарушающих законы вашей страны или региона, включая, помимо прочего, обход законно установленных ограничений доступа к сети, распространение незаконной информации, сетевые атаки и нарушение законных прав других лиц.",
"disclaimerResponsibilityTitle": "Ваша ответственность",
"disclaimerResponsibilityContent": "Вы самостоятельно убеждаетесь, что использование Программы законно в вашей стране или регионе, и единолично несёте юридическую ответственность за все действия с ней и их последствия.\n\nПодписки, узлы и конфигурации, которые вы импортируете, выбираете вы сами. Законность их источника, безопасность содержимого и надёжность сервиса — вопрос между вами и их поставщиками.",
"disclaimerThirdPartyTitle": "Сторонний контент",
"disclaimerThirdPartyContent": "Ссылки на подписки, файлы конфигурации, наборы правил, скрипты, внешние ресурсы и внешние ссылки предоставляются третьими лицами. Разработчики не могут проверять и не проверяют их законность, точность, безопасность и доступность и не дают на них никаких гарантий.\n\nУтечка данных, финансовые потери, блокировка аккаунта или иной ущерб, вызванные сторонним контентом, урегулируются между вами и третьим лицом; разработчики не несут за это никакой ответственности.",
"disclaimerWarrantyTitle": "Отсутствие гарантий",
"disclaimerWarrantyContent": "Программа предоставляется «как есть» и «по мере доступности», без каких-либо явных или подразумеваемых гарантий, включая, помимо прочего, гарантии товарной пригодности, пригодности для определённой цели, ненарушения прав, бесперебойной работы, отсутствия ошибок и уязвимостей.\n\nРазработчики не гарантируют, что Программа будет соответствовать вашим потребностям или работать без сбоев и ошибок.",
"disclaimerLiabilityTitle": "Ограничение ответственности",
"disclaimerLiabilityContent": "В максимальной степени, допустимой применимым законодательством, ни разработчики, ни участники проекта не несут ответственности за любой прямой, косвенный, случайный, особый, штрафной или последующий ущерб, возникший в результате использования или невозможности использования Программы, включая, помимо прочего, потерю данных, повреждение устройства, сбои сети, прерывание деятельности, упущенную выгоду или связанные с этим правовые споры, даже если они были предупреждены о возможности такого ущерба.",
"disclaimerPrivacyTitle": "Сбор данных и конфиденциальность",
"disclaimerPrivacyContent": "Программа не собирает и не отправляет адреса ваших подписок, сведения об узлах, содержимое конфигураций, посещённые сайты, записи о подключениях, содержимое трафика и журналы. Эти данные хранятся только на вашем устройстве, и у разработчиков нет к ним доступа.\n\nПрограмма обращается к сети только при использовании соответствующих функций, например загружает указанный вами адрес подписки при обновлении профиля или обращается к GitHub при проверке обновлений.\n\nНастольные версии (Windows, macOS, Linux) не содержат никаких сервисов статистики или отчётов о сбоях. Версия для Android использует два сервиса Google Firebase для повышения стабильности:",
"disclaimerCrashlyticsTitle": "Firebase Crashlytics (аналитика сбоев)",
"disclaimerCrashlyticsContent": "При сбое приложения отчёт о сбое отправляется автоматически.\n\nЧто собирается: трассировка стека и сообщение об ошибке, время сбоя, версия и номер сборки приложения, производитель и модель устройства, версия Android, ориентация экрана, свободная память и место в хранилище, наличие root-доступа, а также случайный идентификатор установки, который создаётся при установке и сбрасывается при переустановке.\n\nЦель: только поиск и исправление сбоев.\n\nВы можете отключить это в любой момент: «Инструменты > Общие > Аналитика сбоев».",
"disclaimerAnalyticsTitle": "Firebase Analytics (статистика использования)",
"disclaimerAnalyticsContent": "Вместе с Firebase автоматически собирается базовая статистика использования приложения.\n\nЧто собирается: базовые события, такие как первый запуск, открытие приложения и длительность сеанса, обновление приложения; идентификатор экземпляра приложения; модель устройства, версия ОС и язык системы; приблизительное местоположение на уровне страны или региона, определённое по IP-адресу.\n\nЦель: только оценка числа активных устройств, распределения версий и совместимости с ОС. Разработчики не используют эти данные для рекламы, не продают их и не связывают с вашими подписками или конфигурациями.",
"disclaimerDataProcessingContent": "Эти данные обрабатываются и хранятся компанией Google от нашего имени, могут передаваться на серверы за пределами вашей страны или региона (например, в США) и регулируются Политикой конфиденциальности Google и документацией Firebase о конфиденциальности и безопасности. Отчёты о сбоях хранятся до 90 дней; статистика хранится в соответствии с политикой хранения Firebase по умолчанию.",
"disclaimerLicenseTitle": "Лицензия с открытым исходным кодом",
"disclaimerLicenseContent": "Программа распространяется с открытым исходным кодом по лицензии GPL-3.0. Вы можете свободно использовать, изменять и распространять её при соблюдении этой лицензии: производные работы также должны распространяться по GPL-3.0 с сохранением уведомлений об авторских правах.\n\nСторонние компоненты Программы, включая ядро Clash.Meta, распространяются по своим собственным лицензиям. Авторы оригинала не несут ответственности за проблемы, возникшие в изменённых или распространяемых третьими лицами версиях.",
"disclaimerChangesTitle": "Изменения заявления",
"disclaimerChangesContent": "Разработчики могут изменять это заявление в любом выпуске; изменения вступают в силу с момента публикации выпуска. Продолжая пользоваться Программой после обновления, вы принимаете изменённое заявление.",
"disclaimerAcceptTitle": "Принятие условий",
"disclaimerAcceptContent": "Устанавливая, копируя или используя Программу, вы подтверждаете, что прочитали и приняли это заявление полностью. Если вы не согласны с каким-либо его условием, немедленно прекратите использование и удалите Программу.",
"disclaimerAndroidOnly": "Только Android",
"disclaimerGooglePrivacy": "Политика конфиденциальности Google",
"disclaimerFirebasePrivacy": "Конфиденциальность и безопасность Firebase",
"agree": "Согласен",
"hotkeyManagement": "Горячие клавиши",
"hotkeyManagementDesc": "Управление приложением с клавиатуры",
"pressKeyboard": "Нажмите клавишу",
"inputCorrectHotkey": "Введите корректную горячую клавишу",
"hotkeyConflict": "Конфликт горячих клавиш",
"hotkeyDesc": "Глобальные горячие клавиши работают, даже когда окно скрыто. Нажмите на действие, чтобы записать сочетание клавиш.",
"hotkeyNotSet": "Не задано",
"pressKeyboard": "Нажмите сочетание клавиш",
"hotkeyNeedsModifier": "Добавьте хотя бы одну из клавиш: {modifiers}",
"hotkeyConflictWith": "Уже используется для «{action}». При сохранении будет перенесено сюда.",
"hotkeyUnavailable": "Не зарегистрировано: сочетание может быть занято другим приложением",
"remove": "Убрать",
"noHotKey": "Горячих клавиш пока нет",
"noNetwork": "Нет сети",
"ipv6InboundDesc": "Разрешить входящий IPv6",
"exportLogs": "Экспорт логов",
"exportSuccess": "Экспорт выполнен",
"iconStyle": "Стиль значков",
"onlyIcon": "Только значок",
"iconStyleFilled": "С подложкой",
"iconStylePlain": "Без подложки",
"iconStyleHidden": "Скрыто",
"filter": "Фильтр",
"hideTimeoutProxies": "Скрывать узлы с таймаутом",
"hideTimeoutProxiesDesc": "Не показывать узлы, у которых последний тест задержки завершился таймаутом",
"stackMode": "Режим стека",
"network": "Сеть",
"networkDesc": "Настройки, связанные с сетью",
"bypassDomain": "Исключённые домены",
"bypassDomainDesc": "Действует только при включённом системном прокси",
"resetTip": "Вы уверены, что хотите выполнить сброс?",
@@ -284,7 +342,6 @@
"routeModeBypassPrivate": "Обходить частные адреса",
"routeModeConfig": "Использовать конфигурацию",
"routeAddress": "Адреса маршрутов",
"routeAddressDesc": "Настроить прослушиваемые адреса маршрутов",
"interfaceNameMode": "Исходящий интерфейс",
"interfaceNameModeClear": "Очистить",
"interfaceNameModeFollow": "Как в конфигурации",
@@ -293,6 +350,20 @@
"interfaceNameDesc": "Сетевой интерфейс для исходящих соединений",
"copyEnvVar": "Копировать переменные окружения",
"memoryInfo": "Память",
"total": "Всего",
"releaseMemory": "Освободить память",
"memoryCoreHeapInuse": "Используемая куча",
"memoryCoreHeapIdle": "Свободная куча",
"memoryCoreStack": "Стеки горутин",
"memoryCoreRuntime": "Накладные расходы среды",
"memoryCoreNotRunning": "Ядро не запущено",
"memoryAppResident": "Резидентная память",
"memoryAppShared": "Приложение и общая",
"memoryEstimateDesc": "Оценка по резидентной памяти процессов; может отличаться от данных системы.",
"memoryEstimateSharedDesc": "Ядро работает в процессе приложения. Его доля оценивается по статистике среды выполнения, остальное относится к приложению и общей памяти.",
"memoryReleased": "Память освобождена",
"memoryReleasedSize": "Освобождено {size}",
"releaseMemoryFailed": "Не удалось освободить память",
"cancel": "Отмена",
"fileIsUpdate": "Файл изменён. Сохранить изменения?",
"profileHasUpdate": "Профиль изменён. Отключить автообновление?",
@@ -305,27 +376,27 @@
"listen": "Прослушивание",
"undo": "Отменить",
"redo": "Повторить",
"lineWrap": "Перенос строк",
"none": "Нет",
"basicConfig": "Базовая конфигурация",
"basicConfigDesc": "Глобальное изменение базовой конфигурации",
"advancedConfig": "Расширенная конфигурация",
"advancedConfigDesc": "Разнообразные параметры конфигурации",
"advancedConfigDesc": "Сеть, DNS, добавленные правила и скрипты",
"selectedCountTitle": "Выбрано: {count}",
"addRule": "Добавить правило",
"ruleName": "Название правила",
"content": "Содержимое",
"subRule": "Подправило",
"ruleTarget": "Цель правила",
"matchTargetTitle": "Цель MATCH",
"matchTarget": "MATCH-TARGET",
"followProfile": "Как в профиле",
"matchTargetDesc": "Куда направляются правила с целью MATCH-TARGET. По умолчанию — цель последнего правила MATCH этого профиля.",
"selectMatchTarget": "Выбрать MATCH-TARGET",
"sourceIp": "IP источника",
"noResolve": "Не разрешать IP",
"saveChanges": "Сохранить изменения?",
"findProcessModeDesc": "При включении возможна небольшая потеря производительности",
"tabAnimationDesc": "Действует только в мобильном виде",
"navigationBarStyle": "Нижняя панель",
"floating": "Плавающая",
"docked": "Закреплённая",
"slide": "Сдвиг",
"fade": "Растворение",
"pureBlack": "Чисто чёрный",
"colorSchemes": "Цветовые схемы",
"palette": "Палитра",
"tonalSpotScheme": "Тональный акцент",
@@ -348,6 +419,8 @@
"launchInterruptedTip": "В прошлый раз приложение неожиданно завершилось во время запуска. Автоматическая настройка для этого запуска пропущена; вы можете запустить её вручную.",
"clearData": "Очистить данные",
"textScale": "Масштаб текста",
"textScalePreview": "Так будет выглядеть текст в приложении",
"followSystem": "Как в системе",
"internet": "Интернет",
"systemApp": "Системные приложения",
"noNetworkApp": "Приложения без сети",
@@ -361,7 +434,13 @@
"interval": "Интервал",
"existsTip": "«{label}» уже существует",
"maxLengthTip": "«{label}» — не более {max} символов",
"multipleValuesTip": "Разделяйте несколько значений запятыми",
"singleAdd": "По одному",
"batchAdd": "Массовое добавление",
"batchListInputTip": "По одному значению на строку или через запятую",
"batchMapInputTip": "По одной записи на строку: ключ, пробел, значение",
"batchPreviewTip": "Будет добавлено: {count}, пропущено (уже есть): {skipped}",
"lineIssueTip": "Строка {line}: {message}",
"singleValueTip": "«{label}» — только одно значение",
"deleteTip": "Вы уверены, что хотите удалить «{label}»?",
"deleteMultipTip": "Вы уверены, что хотите удалить выбранные элементы ({label})?",
"nullTip": "Пока нет: {label}",
@@ -378,6 +457,7 @@
"import": "Импорт",
"importFile": "Импорт из файла",
"importUrl": "Импорт по URL",
"startFromScratch": "С нуля",
"autoSetSystemDns": "Автонастройка системного DNS",
"details": "Сведения: {label}",
"creationTime": "Время создания",
@@ -396,7 +476,7 @@
"request": "Запрос",
"connected": "Подключено",
"disconnected": "Отключено",
"connecting": "Подключение...",
"connecting": "Подключение…",
"restartCoreTip": "Вы уверены, что хотите перезапустить ядро?",
"forceRestartCoreTip": "Вы уверены, что хотите принудительно перезапустить ядро?",
"dnsHijacking": "Перехват DNS",
@@ -406,20 +486,25 @@
"crashlytics": "Аналитика сбоев",
"crashlyticsTip": "При включении в случае сбоя приложения автоматически загружаются логи сбоя без конфиденциальной информации",
"appendSystemDns": "Добавлять системный DNS",
"appendSystemDnsTip": "Принудительно добавлять системный DNS в конфигурацию",
"editRule": "Редактировать правило",
"overrideMode": "Режим переопределения",
"standardModeDesc": "Стандартный режим: переопределяет базовую конфигурацию и позволяет просто добавлять правила",
"scriptModeDesc": "Режим скрипта: использует внешние скрипты-расширения для переопределения конфигурации в один клик",
"addedRules": "Добавленные правила",
"controlGlobalAddedRules": "Управление глобальными добавленными правилами",
"quickAdd": "Быстрое добавление",
"rulePresetBlockQuic": "Блокировать QUIC",
"rulePresetBlockStun": "Блокировать STUN",
"rulePresetBlockDot": "Блокировать DNS over TLS",
"rulePresetSystemServicesDirect": "Apple и Microsoft напрямую",
"rulePresetBittorrentDirect": "BitTorrent напрямую",
"rulePresetLanDirect": "Локальная сеть напрямую",
"overrideScript": "Скрипт переопределения",
"goToConfigureScript": "Перейти к настройке скрипта",
"editGlobalRules": "Редактировать глобальные правила",
"externalFetch": "Внешнее получение",
"confirmForceCrashCore": "Вы уверены, что хотите принудительно завершить ядро со сбоем?",
"confirmClearAllData": "Вы уверены, что хотите удалить все данные?",
"loading": "Загрузка...",
"loading": "Загрузка…",
"yearsAgo": "{count, plural, one{{count} год назад} few{{count} года назад} many{{count} лет назад} other{{count} года назад}}",
"monthsAgo": "{count, plural, one{{count} месяц назад} few{{count} месяца назад} many{{count} месяцев назад} other{{count} месяца назад}}",
"daysAgo": "{count, plural, one{{count} день назад} few{{count} дня назад} many{{count} дней назад} other{{count} дня назад}}",
@@ -435,24 +520,31 @@
"speedStatistics": "Статистика скорости",
"resetPageChangesTip": "На этой странице есть изменения. Вы уверены, что хотите выполнить сброс?",
"overwriteTypeCustom": "Пользовательский",
"overwriteTypeCustomDesc": "Пользовательский режим: полная настройка групп прокси и правил",
"overwriteTypeCustomDesc": "Пользовательский режим: полная настройка прокси, групп прокси и правил",
"unknownNetworkError": "Неизвестная сетевая ошибка",
"restoreException": "Ошибка восстановления",
"networkException": "Ошибка сети. Проверьте подключение и повторите попытку",
"backupFromNewerVersion": "Резервная копия создана более новой версией приложения. Обновите приложение перед восстановлением",
"networkTimeoutError": "Время ожидания запроса истекло. Проверьте сеть или прокси и повторите попытку",
"networkHostLookupError": "Не удалось определить адрес сервера. Проверьте правильность URL и работу DNS",
"networkConnectionError": "Не удалось подключиться к серверу. Проверьте подключение к сети или настройки прокси",
"networkTlsError": "Не удалось установить защищённое соединение. Сертификат сервера может быть недействителен, или соединение перехватывается",
"networkCancelledError": "Запрос отменён",
"networkAccessDeniedError": "Сервер запретил доступ (HTTP {code}). Возможно, ссылка устарела или учётные данные неверны",
"networkNotFoundError": "По этому адресу ничего не найдено (HTTP {code}). Проверьте правильность URL",
"networkRateLimitedError": "Слишком много запросов (HTTP 429). Подождите немного и повторите попытку",
"networkServerError": "На сервере произошла ошибка (HTTP {code}). Повторите попытку позже",
"networkBadResponseError": "Сервер отклонил запрос (HTTP {code})",
"networkRequestFailed": "Сетевой запрос не выполнен: {detail}",
"invalidBackupFile": "Недопустимый файл резервной копии",
"pruneCache": "Очистить кэш",
"backupAndRestore": "Резервное копирование и восстановление",
"backupAndRestoreDesc": "Синхронизация данных через WebDAV или файлы",
"restore": "Восстановить",
"restoreSuccess": "Восстановление выполнено",
"restoreFromWebDAVDesc": "Восстановить данные из WebDAV",
"restoreFromFileDesc": "Восстановить данные из файла",
"restoreOnlyConfig": "Восстановить только профили",
"restoreAllData": "Восстановить все данные",
"addProfile": "Добавить профиль",
"delayTest": "Тест задержки",
"proxyGroupEmpty": "Группа прокси пуста",
"proxyGroupNameEmpty": "Название группы прокси не может быть пустым",
"proxyGroupNameDuplicate": "Название группы прокси уже используется",
"confirmExitWindow": "Вы уверены, что хотите закрыть текущее окно?",
"dataChangedSave": "Обнаружены изменения данных. Сохранить их?",
@@ -464,6 +556,8 @@
"excludeProxyFilter": "Фильтр исключения узлов",
"excludeType": "Исключаемые типы",
"expectedStatus": "Ожидаемый статус",
"strategy": "Стратегия",
"tolerance": "Допуск",
"selectProxies": "Выбрать прокси",
"inputProxyGroupName": "Введите название группы прокси",
"helperCorruptTip": "Служба Helper недоступна, поэтому TUN-режим включить нельзя. Переустановите FlClash.",
@@ -488,7 +582,7 @@
"basicStrategy": "Базовые политики",
"editProxy": "Редактировать прокси",
"includeAllProxyProviders": "Включить всех провайдеров прокси",
"includeAllProxyProvidersTip": "При включении переопределяет подключённых провайдеров прокси",
"includeAllProxyProvidersTip": "При включении группа берёт всех провайдеров прокси этого профиля: собственных провайдеров подписки, а также профили и прокси-провайдеры приложения, которые использует любая группа прокси",
"addProxyProviders": "Добавить провайдеров прокси",
"includeAllProxies": "Включить все прокси",
"includeAllProxiesTip": "Подключает все прокси вне групп; ниже можно добавить дополнительные группы прокси",
@@ -550,17 +644,16 @@
"invalidSubRule": "{subRule} — недопустимый SUB_RULE",
"invalidPolicy": "{target} — недопустимая политика",
"invalidProxyProvider": "{providerName} — недопустимый провайдер прокси",
"invalidRuleSet": "{ruleSet} — недопустимый набор правил",
"invalidNetworkContent": "Поддерживаются только tcp и udp",
"invalidRangeContent": "Введите числа или диапазоны, например 80 или 8000-9000, через /",
"invalidDscpContent": "Метка DSCP не может превышать 63",
"invalidProxy": "{proxyName} — недопустимый прокси",
"proxyGroupDetectedAbnormal": "Обнаружены отклонения в текущей группе прокси",
"proxyProviderDetectedAbnormal": "Обнаружены отклонения в выбранных провайдерах прокси",
"proxyDetectedAbnormal": "Обнаружены отклонения в выбранных прокси",
"createProfile": "Создать профиль",
"locationPermissionRequired": "Требуется разрешение на геолокацию",
"locationPermissionGuide": "1. Откройте Системные настройки > Конфиденциальность и безопасность\n2. Выберите Службы геолокации\n3. Найдите и отметьте {appName} в списке\n\nПосле настройки вернитесь в приложение и продолжайте работу. Спасибо за сотрудничество.",
"prerequisites": "Предварительные условия",
"ignoreBatteryOptimization": "Игнорировать оптимизацию батареи",
"batteryOptimizationDesc": "Чтобы приложение работало в фоне, отключите для него оптимизацию батареи. Нажмите, чтобы перейти к настройкам.",
"batteryOptimizationStatusTip": "Из-за системных ограничений во время работы невозможно корректно получить статус оптимизации батареи",
"locationPermission": "Разрешение на геолокацию",
"locationPermissionDesc": "По требованию системы для получения имени сети Wi-Fi необходимо разрешение на геолокацию. На Android выберите «Разрешить всегда», иначе имя сети Wi-Fi нельзя получить, пока приложение в фоне.",
"excludeSsids": "Исключённые SSID",
@@ -573,7 +666,7 @@
"editSsid": "Изменить SSID",
"authorized": "Разрешено",
"tapToAuthorize": "Нажмите, чтобы разрешить",
"suspended": "Приостановлено...",
"suspended": "Приостановлено…",
"geoOptions": "Настройки Geo",
"geoAutoUpdate": "Автообновление",
"geoAutoUpdateInterval": "Интервал автообновления",
@@ -584,7 +677,6 @@
"geoSkipped": "{name}: уже последняя версия",
"geoUpdated": "{name}: обновлено",
"secondsCount": "{count, plural, one{{count} секунда} few{{count} секунды} many{{count} секунд} other{{count} секунды}}",
"entriesCount": "{count, plural, one{{count} запись} few{{count} записи} many{{count} записей} other{{count} записи}}",
"proxiesCount": "{count} прокси",
"rulesCount": "{count, plural, one{{count} правило} few{{count} правила} many{{count} правил} other{{count} правила}}",
"changelogBreaking": "Важные изменения",
@@ -600,7 +692,6 @@
"pinWindow": "Закрепить поверх всех окон",
"unpinWindow": "Открепить окно",
"exitFullScreen": "Выйти из полноэкранного режима",
"toggleLabel": "Переключить подписи",
"torch": "Фонарик",
"pickFromAlbum": "Выбрать из галереи",
"blockConnection": "Заблокировать соединение",
@@ -611,11 +702,76 @@
"previousMatch": "Предыдущее совпадение",
"nextMatch": "Следующее совпадение",
"clearSearch": "Очистить поиск",
"noSearchResults": "Ничего не найдено",
"addWidget": "Добавить виджет",
"showPassword": "Показать пароль",
"hidePassword": "Скрыть пароль",
"authorize": "Разрешить",
"installedAppsPermissionRequired": "Требуется разрешение на список приложений",
"installedAppsPermissionDesc": "Эта система не выдаёт список установленных приложений без разрешения. Предоставьте его, чтобы настроить прокси для отдельных приложений.",
"installedAppsPermissionDeniedMessage": "Разрешение на список приложений отклонено, поэтому установленные приложения недоступны. Предоставьте его вручную в системных настройках."
"installedAppsPermissionDeniedMessage": "Разрешение на список приложений отклонено, поэтому установленные приложения недоступны. Предоставьте его вручную в системных настройках.",
"outboundIp": "Исходящий IP",
"ipAddress": "IP-адрес",
"ipQualityLevel": "Уровень",
"ipQualityGood": "Хороший",
"ipQualityNormal": "Обычный",
"ipQualityRisky": "Рискованный",
"ipType": "Тип",
"ipTypeResidential": "Домашний",
"ipTypeMobile": "Мобильная сеть",
"ipTypeBusiness": "Бизнес",
"ipTypeHosting": "Дата-центр",
"ipFlags": "Метки",
"ipFlagProxy": "Прокси",
"ipFlagVpn": "VPN",
"ipFlagTor": "Tor",
"ipFlagAbuser": "Злоупотребления",
"ipOrganization": "Организация",
"ipAsn": "ASN",
"ipQualitySource": "Источник ответа",
"ipQualitySources": "Источники",
"ipQualityFailed": "Не удалось определить тип IP",
"ipQualityRetry": "Проверить снова",
"ipSourceNoType": "Тип не определён",
"ipSourceRateLimited": "Лимит запросов",
"ipSourceIpMismatch": "Другой исходящий IP",
"hideIp": "Скрыть IP",
"editorUnavailable": "Редактор недоступен",
"replace": "Заменить",
"replaceAll": "Заменить все",
"cameraPermissionRequired": "Требуется доступ к камере",
"cameraPermissionDesc": "Разрешите доступ к камере в системных настройках, чтобы сканировать QR-коды, или выберите изображение QR-кода из галереи.",
"cameraUnavailable": "Камера недоступна",
"retry": "Повторить",
"invalidProfileQrcode": "Этот QR-код не содержит ссылку на профиль",
"dnsQueries": "DNS-запросы",
"recordType": "Тип записи",
"answers": "Ответы",
"responseCode": "Код ответа",
"error": "Ошибка",
"initiator": "Инициатор",
"cache": "Кэш",
"proxyNode": "Прокси-узел",
"yes": "Да",
"no": "Нет",
"nonTextProviderFile": "Этот внешний ресурс не является текстовым файлом",
"addCustomProxy": "Добавить прокси",
"proxyDefinition": "Полная конфигурация",
"proxyDefinitionNotMap": "Конфигурация должна быть YAML-словарём с полями name и type",
"customProxiesEmpty": "Пользовательских прокси нет, поэтому используются прокси самого профиля",
"overwriteIssueEmptyName": "Имя не задано",
"overwriteIssueReservedName": "{name} — встроенное имя политики, его нельзя использовать",
"overwriteIssueDuplicateName": "Имя {name} уже занято другим прокси или группой прокси",
"overwriteIssueCoreRejected": "Ядро не может разобрать этот прокси: {message}",
"overwriteIssueMissingProxies": "Эти прокси или политики не существуют: {names}",
"overwriteIssueMissingProviders": "Эти провайдеры прокси не существуют: {names}",
"overwriteIssueNoProxySource": "Не выбраны ни прокси, ни провайдеры прокси, поэтому ядро отклонит эту группу",
"overwriteIssueGroupLoop": "Группы прокси ссылаются друг на друга по кругу: {path}",
"overwriteIssuesSummary": "Проблем: {count}, применение переопределения может завершиться ошибкой",
"overwriteIssueSubscriptionGroupMissingProxies": "Собственные группы прокси профиля ссылаются на прокси, которых больше нет среди пользовательских: {names}",
"overwriteIssueProviderShadowed": "В профиле уже есть провайдер с именем {name}, поэтому провайдер приложения с тем же именем не используется. Переименуйте его",
"providerInUse": "«{label}» всё ещё используется в пользовательских группах прокси или правилах профилей: {profiles}. Сначала уберите его оттуда",
"providerSourceSubscription": "Подписка",
"providerRenameShadowed": "В подписках профилей {profiles} уже есть «{label}», и после переименования они будут использовать его. Выберите другое имя",
"appProviderShadowed": "Одноимённый элемент приложения здесь не используется"
}
+245 -89
View File
@@ -9,19 +9,32 @@
"profiles": "配置",
"tools": "工具",
"logs": "日志",
"logsDesc": "日志捕获记录",
"resources": "资源",
"resourcesDesc": "外部资源相关信息",
"trafficUsage": "流量统计",
"networkSpeed": "网络速度",
"outboundMode": "出站模式",
"networkDetection": "网络检测",
"serviceStatus": "服务状态",
"serviceRestricted": "访问受限",
"serviceAvailable": "可用",
"serviceUnavailable": "不可用",
"serviceFailed": "检测失败",
"serviceDisallowedIsp": "不允许的 ISP",
"serviceBlocked": "已被封禁",
"serviceUnsupportedRegion": "地区不支持",
"serviceOriginalsOnly": "仅限自制内容",
"serviceComingSoon": "即将上线",
"servicePending": "待检测",
"serviceManage": "管理服务",
"serviceCheck": "检测",
"serviceCheckAll": "全部检测",
"serviceCheckedAt": "检测于 {time}",
"upload": "上传",
"download": "下载",
"usedTraffic": "已用流量",
"totalTraffic": "总流量",
"expireTime": "到期时间",
"nullProfileDesc": "没有配置文件,请先添加配置文件",
"nullProfileDesc": "添加一个配置文件后即可开始使用",
"settings": "设置",
"language": "语言",
"defaultText": "默认",
@@ -36,27 +49,22 @@
"themeDesc": "设置深色模式,调整色彩",
"override": "覆写",
"allowLan": "局域网代理",
"allowLanDesc": "允许通过局域网访问代理",
"tun": "虚拟网卡",
"tunDesc": "仅在管理员模式生效",
"minimizeOnExit": "退出时最小化",
"minimizeOnExitDesc": "修改系统默认退出事件",
"autoLaunch": "自启动",
"autoLaunchDesc": "跟随系统自启动",
"silentLaunch": "静默启动",
"silentLaunchDesc": "后台启动",
"silentLaunchDesc": "启动时不显示窗口",
"autoRun": "自动运行",
"autoRunDesc": "应用打开时自动运行",
"logcat": "日志捕获",
"logcatDesc": "禁用将会隐藏日志入口",
"autoCheckUpdate": "自动检查更新",
"autoCheckUpdateDesc": "应用启动时自动检查更新",
"checkCertificate": "校验 TLS 证书",
"checkCertificateDesc": "拒绝不受信任的证书。关闭后订阅和备份将暴露于中间人攻击",
"accessControl": "访问控制",
"accessControlDesc": "配置应用访问代理",
"application": "应用程序",
"applicationDesc": "修改应用程序相关设置",
"edit": "编辑",
"confirm": "确定",
"update": "更新",
@@ -103,12 +111,14 @@
"show": "显示",
"exit": "退出",
"systemProxy": "系统代理",
"safeMode": "安全模式",
"safeModeAppTitle": "{appName}(安全模式)",
"project": "项目",
"core": "内核",
"tabAnimation": "选项卡动画",
"desc": "基于ClashMeta的多平台代理客户端,简单易用,开源无广告。",
"startVpn": "正在启动VPN...",
"stopVpn": "正在停止VPN...",
"startVpn": "正在启动VPN…",
"stopVpn": "正在停止VPN…",
"compatible": "兼容模式",
"notSelectedTip": "当前代理组无法选中",
"changeProxyFailedTip": "切换代理失败,已恢复上一次的选择",
@@ -137,28 +147,26 @@
"country": "区域",
"search": "搜索",
"allowBypass": "允许应用绕过VPN",
"allowBypassDesc": "开启后部分应用可绕过VPN",
"externalController": "外部控制器",
"externalControllerDesc": "开启后将可以通过9090端口控制Clash内核",
"ipv6Desc": "开启后将可以接收IPv6流量",
"app": "应用",
"general": "常规",
"systemProxyDesc": "设置系统代理",
"startupAndBackground": "启动与后台",
"requestsAndUpdates": "请求与更新",
"logsAndDiagnostics": "日志与诊断",
"userAgent": "用户代理",
"unifiedDelay": "统一延迟",
"unifiedDelayDesc": "去除握手等额外延迟",
"tcpConcurrent": "TCP并发",
"tcpConcurrentDesc": "开启后允许TCP并发",
"geodataLoader": "Geo低内存模式",
"geodataLoaderDesc": "开启将使用Geo低内存加载器",
"requests": "请求",
"requestsDesc": "查看最近请求记录",
"recentRequests": "最近请求",
"findProcessMode": "查找进程",
"init": "初始化",
"infiniteTime": "长期有效",
"connections": "连接",
"liveConnections": "实时连接",
"inbound": "入站",
"connectionsDesc": "查看当前连接数据",
"intranetIP": "内网 IP",
"view": "查看",
"cut": "剪切",
@@ -167,7 +175,7 @@
"testUrl": "测速链接",
"sync": "同步",
"exclude": "从最近任务中隐藏",
"excludeDesc": "应用在后台时,从最近任务中隐藏应用",
"excludeDesc": "应用在后台时,从最近任务中隐藏应用",
"expand": "标准",
"shrink": "紧凑",
"min": "最小",
@@ -182,19 +190,18 @@
"go": "前往",
"externalLink": "外部链接",
"otherContributors": "其他贡献者",
"appIconDesign": "应用图标设计",
"autoCloseConnections": "自动关闭连接",
"autoCloseConnectionsDesc": "切换节点后自动关闭连接",
"onlyStatisticsProxy": "仅统计代理",
"onlyStatisticsProxyDesc": "开启后,将只统计代理流量",
"onlyStatisticsProxy": "仅统计代理流量",
"showNotificationStopAction": "通知栏显示停止按钮",
"showNotificationStopActionDesc": "在常驻通知上显示停止按钮。若系统因此总是展开通知,可关闭",
"pureBlackMode": "纯黑模式",
"sidebarBlur": "侧边栏背景模糊",
"sidebarBlurDesc": "侧边栏透出窗口后方模糊的桌面",
"keepAliveIntervalDesc": "TCP保持活动间隔",
"entries": "个条目",
"local": "本地",
"remote": "远程",
"remoteBackupDesc": "备份数据到WebDAV",
"localBackupDesc": "备份数据到本地",
"mode": "模式",
"time": "时间",
"source": "来源",
@@ -208,73 +215,124 @@
"loose": "宽松",
"profilesSort": "配置排序",
"start": "启动",
"runTime": "启动时间",
"stop": "暂停",
"dnsDesc": "更新DNS相关设置",
"key": "键",
"value": "值",
"hostsDesc": "追加Hosts",
"vpnTip": "重启VPN后改变生效",
"vpnEnableDesc": "通过VpnService自动路由系统所有流量",
"options": "选项",
"loopback": "回环解锁工具",
"loopbackDesc": "用于UWP回环解锁",
"enabled": "已开启",
"disabled": "已关闭",
"loopback": "UWP 回环解锁",
"providers": "外部资源",
"proxyProviders": "代理集",
"ruleProviders": "规则集",
"appProxyProviders": "应用代理集",
"appRuleProviders": "应用规则集",
"behavior": "行为",
"format": "格式",
"providerUrlTip": "仅支持远程资源",
"subscriptionInfo": "订阅信息",
"overrideDns": "覆写DNS",
"overrideDnsDesc": "开启后将覆盖配置中的DNS选项",
"overrideNtp": "覆写NTP",
"ntpStatusDesc": "从NTP服务器获取时间,而非系统时钟",
"server": "服务器",
"ntpInterval": "同步间隔(分钟)",
"dialerProxy": "拨号代理",
"dialerProxyDesc": "用于连接NTP服务器的出站",
"writeToSystem": "写入系统",
"writeToSystemDesc": "同时设置系统时钟,Android上不生效",
"overrideEntries": "覆写项",
"addOverrideEntry": "添加覆写项",
"discardChanges": "是否放弃更改?",
"quickEdit": "快速编辑",
"status": "状态",
"statusDesc": "关闭后将使用系统DNS",
"preferH3Desc": "优先使用DOH的http/3",
"respectRules": "遵守规则",
"respectRulesDesc": "DNS连接跟随rules,需配置proxy-server-nameserver",
"respectRulesDesc": "DNS连接遵守规则,需配置Proxy Server Nameserver",
"dnsMode": "DNS模式",
"fakeipRange": "Fakeip范围",
"fakeipFilter": "Fakeip过滤",
"defaultNameserver": "默认域名服务器",
"defaultNameserverDesc": "用于解析DNS服务器",
"nameserver": "域名服务器",
"nameserverDesc": "用于解析域名",
"fakeipRange": "Fake-IP范围",
"fakeipFilter": "Fake-IP过滤",
"useHosts": "使用Hosts",
"useSystemHosts": "使用系统Hosts",
"nameserverPolicy": "域名服务器策略",
"nameserverPolicyDesc": "指定对应域名服务器策略",
"proxyNameserver": "代理域名服务器",
"proxyNameserverDesc": "用于解析代理节点的域名",
"fallback": "Fallback",
"fallbackDesc": "一般情况下使用境外DNS",
"fallbackFilter": "Fallback过滤",
"geoipCode": "Geoip代码",
"ipcidr": "IP/掩码",
"domain": "域名",
"listenRoutingMark": "监听路由标记",
"listenRoutingMarkDesc": "仅Linux有效",
"ipv6Timeout": "IPv6超时(毫秒)",
"cacheAlgorithm": "缓存算法",
"cacheMaxSize": "缓存大小",
"fakeipRange6": "Fake-IP范围(IPv6)",
"fakeipFilterMode": "Fake-IP过滤模式",
"fakeipFilterModeDesc": "blacklist排除匹配项,whitelist仅用于匹配项,rule按规则匹配",
"fakeipTtl": "Fake-IP TTL",
"reset": "重置",
"actionView": "显示/隐藏",
"actionStart": "启动/停止",
"actionMode": "切换模式",
"actionProxy": "系统代理",
"actionTun": "虚拟网卡",
"actionRuleMode": "规则模式",
"actionGlobalMode": "全局模式",
"actionDirectMode": "直连模式",
"actionDelayTest": "测试全部延迟",
"actionUpdateProfiles": "更新全部配置",
"disclaimer": "免责声明",
"disclaimerDesc": "本软件仅供学习交流、科研等非商业性质的用途,严禁将本软件用于商业目的。如有任何商业行为,均与本软件无关。",
"disclaimerDesc": "在使用 FlClash(以下简称“本软件”)前,请仔细阅读并充分理解本声明的全部内容。点击“同意”即表示你已阅读、理解并接受以下全部条款;如不同意,请点击“退出”并停止使用本软件。",
"disclaimerSoftwareTitle": "软件性质",
"disclaimerSoftwareContent": "本软件是基于 Clash.Meta(mihomo)内核的开源网络代理客户端,仅提供配置管理、规则分流与流量转发等本地工具功能。\n\n本软件自身不提供任何代理服务器、节点、订阅、机场或网络接入服务,也不与任何此类服务的提供者存在合作、代理或担保关系。",
"disclaimerUsageTitle": "使用限制",
"disclaimerUsageContent": "本软件仅供学习交流、技术研究等非商业用途。严禁将本软件用于任何商业目的,包括但不限于付费分发、捆绑销售、作为商业服务的组成部分或以本软件名义开展经营活动;任何商业行为均与本软件及其开发者无关。\n\n严禁将本软件用于违反你所在国家或地区法律法规的活动,包括但不限于绕过依法设置的网络访问限制、传播违法信息、实施网络攻击或侵犯他人合法权益。",
"disclaimerResponsibilityTitle": "用户责任",
"disclaimerResponsibilityContent": "你应自行确认使用本软件在你所在国家或地区是合法的,并对使用本软件的全部行为及其后果独立承担法律责任。\n\n你导入的订阅、节点与配置由你自行选择和判断,其来源是否合法、内容是否安全、服务是否稳定,均由你与相关提供者自行负责。",
"disclaimerThirdPartyTitle": "第三方内容",
"disclaimerThirdPartyContent": "订阅链接、配置文件、规则集、脚本、外部资源及外部链接均由第三方提供,开发者无法也不会对其合法性、准确性、安全性和可用性进行审查或担保。\n\n因使用第三方内容导致的数据泄露、财产损失、账号封禁或其他任何损失,由你与第三方自行解决,开发者不承担任何责任。",
"disclaimerWarrantyTitle": "无担保声明",
"disclaimerWarrantyContent": "本软件按“现状”和“可用”的原则提供,不附带任何明示或默示的担保,包括但不限于适销性、特定用途适用性、不侵权以及持续可用、无错误、无安全漏洞的担保。\n\n开发者不保证本软件能满足你的需求,也不保证本软件的运行不会中断或不会出现错误。",
"disclaimerLiabilityTitle": "责任限制",
"disclaimerLiabilityContent": "在适用法律允许的最大范围内,开发者及所有贡献者均不对因使用或无法使用本软件而产生的任何直接、间接、偶然、特殊、惩罚性或后果性损害承担责任,包括但不限于数据丢失、设备损坏、网络异常、业务中断、利润损失或因此引起的任何法律纠纷,即使已被告知发生此类损害的可能性。",
"disclaimerPrivacyTitle": "数据收集与隐私",
"disclaimerPrivacyContent": "本软件不会收集或上传你的订阅地址、节点信息、配置内容、访问的网站、连接记录、流量内容和日志,这些数据仅保存在你的设备本地,开发者无法获取。\n\n本软件只会在你使用相应功能时访问外部网络,例如更新订阅时访问你提供的订阅地址、检查更新时访问 GitHub。\n\n桌面版(Windows、macOS、Linux)未集成任何数据统计或崩溃上报服务。Android 版集成了 Google Firebase 的以下两项服务,用于改进应用稳定性:",
"disclaimerCrashlyticsTitle": "Firebase Crashlytics(崩溃分析)",
"disclaimerCrashlyticsContent": "应用发生崩溃时,自动上传崩溃报告。\n\n收集内容:崩溃堆栈与错误信息、崩溃发生时间、应用版本与构建号、设备品牌与型号、Android 系统版本、屏幕方向、剩余内存与存储空间、设备是否已 root,以及一个随安装生成、重装后即重置的随机安装标识。\n\n用途:仅用于定位和修复崩溃问题。\n\n你可以在“工具 > 常规 > 崩溃分析”中随时关闭。",
"disclaimerAnalyticsTitle": "Firebase Analytics(使用统计)",
"disclaimerAnalyticsContent": "随 Firebase 自动收集基础的应用使用统计。\n\n收集内容:首次启动、应用打开与会话时长、应用更新等基础事件,应用实例标识,设备型号、系统版本、系统语言,以及由 IP 地址推断的国家或地区级大致位置。\n\n用途:仅用于了解活跃设备数、版本分布与系统兼容情况。开发者不会将这些数据用于广告,不会出售,也不会与你的订阅或配置相关联。",
"disclaimerDataProcessingContent": "上述数据由 Google 代为处理和存储,可能被传输至你所在国家或地区以外(如美国)的服务器,并受 Google 隐私政策与 Firebase 隐私和安全说明约束。崩溃报告最多保留 90 天,统计数据按 Firebase 的默认策略保留。",
"disclaimerLicenseTitle": "开源协议",
"disclaimerLicenseContent": "本软件以 GPL-3.0 协议开源。你可以在遵守该协议的前提下自由使用、修改和分发本软件,但须同样以 GPL-3.0 协议开源并保留原作者的版权声明。\n\n本软件包含的第三方组件(包括 Clash.Meta 内核)分别遵循其各自的开源协议。基于本软件修改或分发的版本所产生的任何问题,与原作者无关。",
"disclaimerChangesTitle": "声明变更",
"disclaimerChangesContent": "开发者有权随版本更新修改本声明,修改后的内容随新版本发布即生效。你在更新后继续使用本软件,即视为接受修改后的声明。",
"disclaimerAcceptTitle": "接受声明",
"disclaimerAcceptContent": "一旦你安装、复制或使用本软件,即视为你已阅读并同意本声明的全部内容。如你不同意本声明的任何条款,请立即停止使用并卸载本软件。",
"disclaimerAndroidOnly": "仅 Android",
"disclaimerGooglePrivacy": "Google 隐私政策",
"disclaimerFirebasePrivacy": "Firebase 隐私和安全说明",
"agree": "同意",
"hotkeyManagement": "快捷键管理",
"hotkeyManagementDesc": "使用键盘控制应用程序",
"pressKeyboard": "请按下按键",
"inputCorrectHotkey": "请输入正确的快捷键",
"hotkeyConflict": "快捷键冲突",
"hotkeyDesc": "全局快捷键在窗口隐藏时依然生效,点击操作即可录制组合键。",
"hotkeyNotSet": "未设置",
"pressKeyboard": "请按下组合键",
"hotkeyNeedsModifier": "至少包含 {modifiers} 中的一个",
"hotkeyConflictWith": "已被“{action}”使用,保存后将移到这里",
"hotkeyUnavailable": "未能注册,可能已被其他应用占用",
"remove": "移除",
"noHotKey": "暂无快捷键",
"noNetwork": "无网络",
"ipv6InboundDesc": "允许IPv6入站",
"exportLogs": "导出日志",
"exportSuccess": "导出成功",
"iconStyle": "图标样式",
"onlyIcon": "仅图标",
"iconStyleFilled": "色块",
"iconStylePlain": "纯图标",
"iconStyleHidden": "隐藏",
"filter": "筛选",
"hideTimeoutProxies": "隐藏超时节点",
"hideTimeoutProxiesDesc": "不显示上次延迟测试超时的节点",
"stackMode": "栈模式",
"network": "网络",
"networkDesc": "修改网络相关设置",
"bypassDomain": "排除域名",
"bypassDomainDesc": "仅在系统代理启用时生效",
"resetTip": "确定要重置吗?",
"resetTip": "确定要重置吗?",
"icon": "图片",
"noData": "暂无数据",
"fontFamily": "字体",
@@ -284,7 +342,6 @@
"routeModeBypassPrivate": "绕过私有路由地址",
"routeModeConfig": "使用配置",
"routeAddress": "路由地址",
"routeAddressDesc": "配置监听路由地址",
"interfaceNameMode": "出站网卡",
"interfaceNameModeClear": "清空",
"interfaceNameModeFollow": "跟随配置",
@@ -293,9 +350,23 @@
"interfaceNameDesc": "出站连接使用的网卡名称",
"copyEnvVar": "复制环境变量",
"memoryInfo": "内存信息",
"total": "总计",
"releaseMemory": "释放内存",
"memoryCoreHeapInuse": "堆内存使用中",
"memoryCoreHeapIdle": "堆内存空闲",
"memoryCoreStack": "协程栈",
"memoryCoreRuntime": "运行时开销",
"memoryCoreNotRunning": "内核未运行",
"memoryAppResident": "常驻内存",
"memoryAppShared": "应用及共享",
"memoryEstimateDesc": "基于进程常驻内存估算,可能与系统显示的数值不同。",
"memoryEstimateSharedDesc": "内核与应用运行在同一进程,内核部分按运行时统计估算,其余计入应用及共享内存。",
"memoryReleased": "内存已释放",
"memoryReleasedSize": "已释放 {size}",
"releaseMemoryFailed": "释放内存失败",
"cancel": "取消",
"fileIsUpdate": "文件有修改,是否保存修改",
"profileHasUpdate": "配置文件已经修改,是否关闭自动更新 ",
"profileHasUpdate": "配置文件已经修改,是否关闭自动更新?",
"hasCacheChange": "是否缓存修改",
"copySuccess": "复制成功",
"copyLink": "复制链接",
@@ -305,27 +376,27 @@
"listen": "监听",
"undo": "撤销",
"redo": "重做",
"lineWrap": "自动换行",
"none": "无",
"basicConfig": "基础配置",
"basicConfigDesc": "全局修改基础配置",
"advancedConfig": "进阶配置",
"advancedConfigDesc": "提供多样化配置",
"advancedConfigDesc": "网络、DNS、附加规则与脚本",
"selectedCountTitle": "已选择 {count} 项",
"addRule": "添加规则",
"ruleName": "规则名称",
"content": "内容",
"subRule": "子规则",
"ruleTarget": "规则目标",
"matchTargetTitle": "匹配目标",
"matchTarget": "MATCH-TARGET",
"followProfile": "跟随配置",
"matchTargetDesc": "目标为 MATCH-TARGET 的规则路由到这里,默认取本配置末尾 MATCH 规则的目标。",
"selectMatchTarget": "选择 MATCH-TARGET",
"sourceIp": "源IP",
"noResolve": "不解析IP",
"saveChanges": "是否保存更改?",
"findProcessModeDesc": "开启后会有一定性能损耗",
"tabAnimationDesc": "仅在移动视图中有效",
"navigationBarStyle": "底栏",
"floating": "悬浮",
"docked": "固定",
"slide": "滑动",
"fade": "淡入",
"pureBlack": "纯黑",
"colorSchemes": "配色方案",
"palette": "调色板",
"tonalSpotScheme": "调性点缀",
@@ -348,6 +419,8 @@
"launchInterruptedTip": "应用上次在启动过程中意外退出。已跳过本次自动配置,你可以手动启动重试。",
"clearData": "清除数据",
"textScale": "文本缩放",
"textScalePreview": "应用内的文字将以这个大小显示",
"followSystem": "跟随系统",
"internet": "互联网",
"systemApp": "系统应用",
"noNetworkApp": "无网络应用",
@@ -361,7 +434,13 @@
"interval": "间隔",
"existsTip": "{label}当前已存在",
"maxLengthTip": "{label}最多{max}个字符",
"multipleValuesTip": "多个值使用逗号分隔",
"singleAdd": "单条添加",
"batchAdd": "批量添加",
"batchListInputTip": "每行一项,也可用逗号分隔",
"batchMapInputTip": "每行一条,键和值之间用空格分隔",
"batchPreviewTip": "将添加 {count} 项,跳过 {skipped} 项已存在",
"lineIssueTip": "第 {line} 行:{message}",
"singleValueTip": "{label}只能是一项",
"deleteTip": "确定删除当前{label}吗?",
"deleteMultipTip": "确定删除选中的{label}吗?",
"nullTip": "暂无{label}",
@@ -378,6 +457,7 @@
"import": "导入",
"importFile": "通过文件导入",
"importUrl": "通过URL导入",
"startFromScratch": "从零开始",
"autoSetSystemDns": "自动设置系统DNS",
"details": "{label}详情",
"creationTime": "创建时间",
@@ -396,7 +476,7 @@
"request": "请求",
"connected": "已连接",
"disconnected": "已断开",
"connecting": "连接中...",
"connecting": "连接中…",
"restartCoreTip": "您确定要重启核心吗?",
"forceRestartCoreTip": "您确定要强制重启核心吗?",
"dnsHijacking": "DNS劫持",
@@ -406,20 +486,25 @@
"crashlytics": "崩溃分析",
"crashlyticsTip": "开启后,应用崩溃时自动上传不包含敏感信息的崩溃日志",
"appendSystemDns": "追加系统DNS",
"appendSystemDnsTip": "强制为配置附加系统DNS",
"editRule": "编辑规则",
"overrideMode": "覆写模式",
"standardModeDesc": "标准模式,覆写基础配置,提供简单追加规则能力",
"scriptModeDesc": "脚本模式,使用外部扩展脚本,提供一键覆写配置的能力",
"addedRules": "附加规则",
"controlGlobalAddedRules": "控制全局附加规则",
"quickAdd": "快捷添加",
"rulePresetBlockQuic": "屏蔽 QUIC",
"rulePresetBlockStun": "屏蔽 STUN",
"rulePresetBlockDot": "屏蔽 DNS over TLS",
"rulePresetSystemServicesDirect": "Apple 与 Microsoft 直连",
"rulePresetBittorrentDirect": "BT 下载直连",
"rulePresetLanDirect": "局域网直连",
"overrideScript": "覆写脚本",
"goToConfigureScript": "前往配置脚本",
"editGlobalRules": "编辑全局规则",
"externalFetch": "外部获取",
"confirmForceCrashCore": "确定要强制崩溃核心?",
"confirmClearAllData": "确定要清除所有数据?",
"loading": "加载中...",
"loading": "加载中…",
"yearsAgo": "{count} 年前",
"monthsAgo": "{count} 个月前",
"daysAgo": "{count} 天前",
@@ -435,26 +520,33 @@
"speedStatistics": "网速统计",
"resetPageChangesTip": "当前页面存在更改,确定重置吗?",
"overwriteTypeCustom": "自定义",
"overwriteTypeCustomDesc": "自定义模式,支持完全自定义修改代理组以及规则",
"overwriteTypeCustomDesc": "自定义模式,支持完全自定义修改代理、策略组以及规则",
"unknownNetworkError": "未知网络错误",
"restoreException": "恢复异常",
"networkException": "网络异常,请检查连接后重试",
"backupFromNewerVersion": "该备份来自更高版本的应用,请先更新应用再恢复",
"networkTimeoutError": "请求超时,请检查网络或代理后重试",
"networkHostLookupError": "无法解析服务器地址,请确认链接正确且 DNS 可用",
"networkConnectionError": "无法连接到服务器,请检查网络连接或代理设置",
"networkTlsError": "安全连接失败,服务器证书可能无效,或连接被拦截",
"networkCancelledError": "请求已取消",
"networkAccessDeniedError": "服务器拒绝访问(HTTP {code}),链接可能已过期或凭据有误",
"networkNotFoundError": "该地址下没有内容(HTTP {code}),请确认链接是否正确",
"networkRateLimitedError": "请求过于频繁(HTTP 429),请稍后再试",
"networkServerError": "服务器出现问题(HTTP {code}),请稍后再试",
"networkBadResponseError": "服务器拒绝了请求(HTTP {code})",
"networkRequestFailed": "网络请求失败:{detail}",
"invalidBackupFile": "无效备份文件",
"pruneCache": "修剪缓存",
"backupAndRestore": "备份与恢复",
"backupAndRestoreDesc": "通过WebDAV或者文件同步数据",
"restore": "恢复",
"restoreSuccess": "恢复成功",
"restoreFromWebDAVDesc": "通过WebDAV恢复数据",
"restoreFromFileDesc": "通过文件恢复数据",
"restoreOnlyConfig": "仅恢复配置文件",
"restoreAllData": "恢复所有数据",
"addProfile": "添加配置",
"delayTest": "延迟测试",
"proxyGroupEmpty": "策略组为空",
"proxyGroupNameEmpty": "策略组名称不能为空",
"proxyGroupNameDuplicate": "策略组名称重复",
"confirmExitWindow": "确定要退出当前窗口吗?",
"confirmExitWindow": "确定要退出当前窗口吗?",
"dataChangedSave": "检测到数据有更改,是否保存",
"selectProxyProviders": "选择代理集",
"proxyFilter": "节点过滤器",
@@ -464,6 +556,8 @@
"excludeProxyFilter": "排除节点过滤器",
"excludeType": "排除类型",
"expectedStatus": "预期状态",
"strategy": "策略",
"tolerance": "容差",
"selectProxies": "选择代理",
"inputProxyGroupName": "输入策略组名称",
"helperCorruptTip": "Helper 服务不可用,无法启用 TUN 模式,请重新安装 FlClash。",
@@ -488,7 +582,7 @@
"basicStrategy": "基础策略",
"editProxy": "编辑代理",
"includeAllProxyProviders": "包含所有代理集",
"includeAllProxyProvidersTip": "开启后将覆盖引入的代理集",
"includeAllProxyProvidersTip": "开启后包含本配置的全部代理集:订阅自带的,以及任一策略组引用的配置和应用代理集",
"addProxyProviders": "添加代理集",
"includeAllProxies": "包含所有代理",
"includeAllProxiesTip": "引入不包含策略组的所有代理,可在下方额外添加策略组",
@@ -510,7 +604,7 @@
"ruleActionDomainWildcardDesc": "通配符匹配,仅支持*和?通配符",
"ruleActionGeositeDesc": "匹配 Geosite 内的域名",
"ruleActionIpCidrDesc": "匹配 IP 地址范围",
"ruleActionIpCidr6Desc": "匹配 IP 地址范围, IP-CIDR6 只是一个别名",
"ruleActionIpCidr6Desc": "匹配 IP 地址范围,IP-CIDR6 只是一个别名",
"ruleActionIpSuffixDesc": "匹配 IP 后缀范围",
"ruleActionIpAsnDesc": "匹配 IP 所属 ASN",
"ruleActionGeoipDesc": "匹配 IP 所属国家代码",
@@ -533,12 +627,12 @@
"ruleActionProcessNameWildcardDesc": "使用进程名称通配符匹配,仅支持*和?通配符",
"ruleActionUidDesc": "匹配 Linux USER ID",
"ruleActionNetworkDesc": "匹配TCP或者UDP",
"ruleActionDscpDesc": "匹配DSCP标记 (仅限 tproxy udp 入站)",
"ruleActionDscpDesc": "匹配DSCP标记(仅限 tproxy udp 入站)",
"ruleActionRuleSetDesc": "引用规则集合,需配置rule-providers",
"ruleActionAndDesc": "逻辑规则 AND",
"ruleActionOrDesc": "逻辑规则 OR",
"ruleActionNotDesc": "逻辑规则 NOT",
"ruleActionSubRuleDesc": "匹配至子规则,需要注意括号的使用",
"ruleActionSubRuleDesc": "匹配至子规则,需要注意括号的使用",
"ruleActionMatchDesc": "匹配所有请求,无需条件",
"subRuleEmpty": "子规则为空",
"proxyProvidersNotEmpty": "代理集不能为空",
@@ -550,17 +644,16 @@
"invalidSubRule": "{subRule} 是一个无效的SUB_RULE",
"invalidPolicy": "{target} 是一个无效的策略",
"invalidProxyProvider": "{providerName} 是一个无效的代理集",
"invalidRuleSet": "{ruleSet} 不是有效的规则集",
"invalidNetworkContent": "仅支持 tcp 或 udp",
"invalidRangeContent": "请输入数字或范围,如 80 或 8000-9000,多个用 / 分隔",
"invalidDscpContent": "DSCP 标记不能超过 63",
"invalidProxy": "{proxyName} 是一个无效的代理",
"proxyGroupDetectedAbnormal": "检测到当前策略组异常",
"proxyProviderDetectedAbnormal": "检测到选中的代理集存在异常",
"proxyDetectedAbnormal": "检测到选中的代理存在异常",
"createProfile": "创建配置",
"locationPermissionRequired": "需要位置权限",
"locationPermissionGuide": "1. 打开 系统设置 > 隐私与安全性\n2. 选择 定位服务\n3. 在右侧列表中找到并勾选 {appName}\n\n完成设置后,返回应用即可正常使用。感谢您的配合。",
"prerequisites": "前置条件",
"ignoreBatteryOptimization": "忽略电池优化",
"batteryOptimizationDesc": "为保证后台运行,请关闭本应用的电池优化。点击前往设置。",
"batteryOptimizationStatusTip": "由于系统限制,运行状态下无法正确获取电池优化状态",
"locationPermission": "位置权限",
"locationPermissionDesc": "根据系统要求,获取Wi-Fi名称需要您授予位置权限。Android 上请选择“始终允许”,否则应用在后台时无法获取 Wi-Fi 名称。",
"excludeSsids": "排除SSIDs",
@@ -573,7 +666,7 @@
"editSsid": "编辑SSID",
"authorized": "已授权",
"tapToAuthorize": "点击授权",
"suspended": "挂起中...",
"suspended": "挂起中…",
"geoOptions": "Geo 选项",
"geoAutoUpdate": "自动更新",
"geoAutoUpdateInterval": "自动更新间隔",
@@ -584,7 +677,6 @@
"geoSkipped": "{name} 已是最新版本",
"geoUpdated": "{name} 已更新",
"secondsCount": "{count} 秒",
"entriesCount": "{count} 个条目",
"proxiesCount": "{count} 个代理",
"rulesCount": "{count} 条规则",
"changelogBreaking": "重大变更",
@@ -600,7 +692,6 @@
"pinWindow": "窗口置顶",
"unpinWindow": "取消置顶",
"exitFullScreen": "退出全屏",
"toggleLabel": "切换标签",
"torch": "手电筒",
"pickFromAlbum": "从相册选择",
"blockConnection": "阻止连接",
@@ -611,11 +702,76 @@
"previousMatch": "上一个匹配",
"nextMatch": "下一个匹配",
"clearSearch": "清除搜索",
"noSearchResults": "没有匹配的结果",
"addWidget": "添加组件",
"showPassword": "显示密码",
"hidePassword": "隐藏密码",
"authorize": "授权",
"installedAppsPermissionRequired": "需要读取应用列表权限",
"installedAppsPermissionDesc": "当前系统在授权前不会提供已安装的应用列表,授权后即可配置分应用代理。",
"installedAppsPermissionDeniedMessage": "读取应用列表权限已被拒绝,无法获取已安装的应用。请前往系统设置手动开启。"
"installedAppsPermissionDeniedMessage": "读取应用列表权限已被拒绝,无法获取已安装的应用。请前往系统设置手动开启。",
"outboundIp": "出站 IP",
"ipAddress": "IP 地址",
"ipQualityLevel": "等级",
"ipQualityGood": "优",
"ipQualityNormal": "普通",
"ipQualityRisky": "风险",
"ipType": "类型",
"ipTypeResidential": "住宅",
"ipTypeMobile": "移动网络",
"ipTypeBusiness": "商业",
"ipTypeHosting": "机房",
"ipFlags": "命中标记",
"ipFlagProxy": "代理",
"ipFlagVpn": "VPN",
"ipFlagTor": "Tor",
"ipFlagAbuser": "滥用记录",
"ipOrganization": "组织",
"ipAsn": "ASN",
"ipQualitySource": "采用来源",
"ipQualitySources": "各来源",
"ipQualityFailed": "类型查询失败",
"ipQualityRetry": "重新查询",
"ipSourceNoType": "无法判定类型",
"ipSourceRateLimited": "限流",
"ipSourceIpMismatch": "出站 IP 不一致",
"hideIp": "隐藏 IP",
"editorUnavailable": "编辑器不可用",
"replace": "替换",
"replaceAll": "全部替换",
"cameraPermissionRequired": "需要相机权限",
"cameraPermissionDesc": "请在系统设置中允许访问相机以扫描二维码,或从相册选择二维码图片。",
"cameraUnavailable": "相机不可用",
"retry": "重试",
"invalidProfileQrcode": "该二维码不包含配置文件链接",
"dnsQueries": "DNS查询",
"recordType": "记录类型",
"answers": "应答",
"responseCode": "响应码",
"error": "错误",
"initiator": "发起方",
"cache": "缓存",
"proxyNode": "代理节点",
"yes": "是",
"no": "否",
"nonTextProviderFile": "该外部资源不是文本文件",
"addCustomProxy": "添加代理",
"proxyDefinition": "完整配置",
"proxyDefinitionNotMap": "配置必须是包含 name 和 type 的 YAML 映射",
"customProxiesEmpty": "未添加自定义代理,沿用配置文件自身的代理",
"overwriteIssueEmptyName": "名称为空",
"overwriteIssueReservedName": "{name} 是内置策略名,不能在此使用",
"overwriteIssueDuplicateName": "名称 {name} 已被其他代理或策略组使用",
"overwriteIssueCoreRejected": "内核无法解析该代理:{message}",
"overwriteIssueMissingProxies": "以下代理或策略不存在:{names}",
"overwriteIssueMissingProviders": "以下代理集不存在:{names}",
"overwriteIssueNoProxySource": "未选择任何代理或代理集,内核会拒绝该策略组",
"overwriteIssueGroupLoop": "策略组之间存在循环引用:{path}",
"overwriteIssuesSummary": "{count} 项存在问题,应用覆写可能失败",
"overwriteIssueSubscriptionGroupMissingProxies": "配置文件自身的策略组引用了自定义代理中已没有的代理:{names}",
"overwriteIssueProviderShadowed": "配置文件中已有名为 {name} 的外部资源,同名的应用级外部资源不会生效,请改名",
"providerInUse": "{label} 仍被 {profiles} 的自定义策略组或规则使用,请先在那里移除",
"providerSourceSubscription": "订阅",
"providerRenameShadowed": "{profiles} 的订阅里已有 {label},改名后这些配置会改用订阅里的那个,请换一个名称",
"appProviderShadowed": "同名的应用级项在此不生效"
}
Binary file not shown.
-9
View File
@@ -1,9 +0,0 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="69" y="69" width="70" height="70" rx="22" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
<rect x="161" y="69" width="70" height="70" rx="35" fill="#6750A4"/>
<rect x="69" y="161" width="70" height="70" rx="35" fill="#6750A4" opacity="0.58"/>
<path d="M174 161H218C225.18 161 231 166.82 231 174V218C231 225.18 225.18 231 218 231H174C166.82 231 161 225.18 161 218V174C161 166.82 166.82 161 174 161Z" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
<path d="M90 104H118" stroke="#6750A4" stroke-width="8" stroke-linecap="round"/>
<path d="M104 90V118" stroke="#6750A4" stroke-width="8" stroke-linecap="round"/>
<path d="M181 196H211" stroke="#6750A4" stroke-width="8" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 827 B

-5
View File
@@ -1,5 +0,0 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M122 150H178" stroke="#6750A4" stroke-width="14" stroke-linecap="round"/>
<circle cx="92" cy="150" r="34" fill="#FDF7FF" stroke="#6750A4" stroke-width="9"/>
<circle cx="208" cy="150" r="34" fill="#6750A4"/>
</svg>

Before

Width:  |  Height:  |  Size: 329 B

-5
View File
@@ -1,5 +0,0 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M82 108C82 90.33 112.45 76 150 76C187.55 76 218 90.33 218 108V197C218 214.67 187.55 229 150 229C112.45 229 82 214.67 82 197V108Z" fill="#6750A4"/>
<ellipse cx="150" cy="108" rx="52" ry="17" fill="#FDF7FF"/>
<path d="M105 161C128 170 172 170 195 161" stroke="#FDF7FF" stroke-width="9" stroke-linecap="round" opacity="0.72"/>
</svg>

Before

Width:  |  Height:  |  Size: 446 B

-4
View File
@@ -1,4 +0,0 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="150" cy="150" r="78" fill="#FDF7FF" stroke="#6750A4" stroke-width="12"/>
<path d="M150 105V154L183 177" stroke="#6750A4" stroke-width="13" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 311 B

-10
View File
@@ -1,10 +0,0 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="57" y="72" width="186" height="156" rx="28" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
<path d="M57 100C57 84.54 69.54 72 85 72H215C230.46 72 243 84.54 243 100V112H57V100Z" fill="#E8DEF8"/>
<circle cx="84" cy="92" r="6" fill="#6750A4"/>
<circle cx="104" cy="92" r="6" fill="#6750A4" opacity="0.55"/>
<path d="M88 150L108 166L88 182" stroke="#6750A4" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M126 183H169" stroke="#6750A4" stroke-width="10" stroke-linecap="round"/>
<circle cx="215" cy="204" r="34" fill="#6750A4" stroke="#FDF7FF" stroke-width="6"/>
<path d="M215 186V204L226 214" stroke="#FDF7FF" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 838 B

-5
View File
@@ -1,5 +0,0 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="91" y="126" width="118" height="92" rx="28" fill="#6750A4"/>
<path d="M116 126V111C116 92 131 77 150 77C169 77 184 92 184 111V126" stroke="#6750A4" stroke-width="15" stroke-linecap="round"/>
<circle cx="150" cy="169" r="10" fill="#FDF7FF"/>
</svg>

Before

Width:  |  Height:  |  Size: 363 B

-14
View File
@@ -1,14 +0,0 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M82 58H182L218 94V234C218 244 210 252 200 252H82C72 252 64 244 64 234V76C64 66 72 58 82 58Z" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3" stroke-linejoin="round"/>
<path d="M182 58V94H218" fill="#E8DEF8"/>
<path d="M182 58V94H218" stroke="#C4C7C5" stroke-width="3" stroke-linejoin="round"/>
<path d="M94 124H188" stroke="#6750A4" stroke-width="8" stroke-linecap="round" opacity="0.55"/>
<circle cx="126" cy="124" r="12" fill="#6750A4"/>
<path d="M94 164H188" stroke="#6750A4" stroke-width="8" stroke-linecap="round" opacity="0.55"/>
<circle cx="166" cy="164" r="12" fill="#6750A4"/>
<path d="M94 204H188" stroke="#6750A4" stroke-width="8" stroke-linecap="round" opacity="0.55"/>
<circle cx="142" cy="204" r="12" fill="#6750A4"/>
<circle cx="213" cy="218" r="33" fill="#6750A4" stroke="#FDF7FF" stroke-width="6"/>
<path d="M193 220C204 207 222 207 233 220" stroke="#FDF7FF" stroke-width="7" stroke-linecap="round"/>
<circle cx="213" cy="204" r="8" fill="#FDF7FF"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-6
View File
@@ -1,6 +0,0 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M150 205V153M150 153L96 99M150 153L204 99" stroke="#6750A4" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="96" cy="99" r="27" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
<circle cx="204" cy="99" r="27" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
<circle cx="150" cy="205" r="30" fill="#6750A4"/>
</svg>

Before

Width:  |  Height:  |  Size: 466 B

-6
View File
@@ -1,6 +0,0 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="96" y="72" width="145" height="156" rx="28" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
<circle cx="120" cy="100" r="6" fill="#6750A4"/>
<circle cx="140" cy="100" r="6" fill="#6750A4" opacity="0.55"/>
<path d="M58 158H190M171 139L192 158L171 177" stroke="#6750A4" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 462 B

-7
View File
@@ -1,7 +0,0 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M63 69H237L174 148V221L126 246V148L63 69Z" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3" stroke-linejoin="round"/>
<path d="M95 101H205" stroke="#6750A4" stroke-width="10" stroke-linecap="round"/>
<path d="M113 127H187" stroke="#6750A4" stroke-width="10" stroke-linecap="round" opacity="0.55"/>
<circle cx="215" cy="205" r="34" fill="#6750A4" stroke="#FDF7FF" stroke-width="6"/>
<path d="M199 205L210 216L231 193" stroke="#FDF7FF" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 626 B

-9
View File
@@ -1,9 +0,0 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="54" y="65" width="192" height="170" rx="30" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
<path d="M54 104H246" stroke="#E8DEF8" stroke-width="18"/>
<circle cx="82" cy="84" r="6" fill="#6750A4"/>
<circle cx="103" cy="84" r="6" fill="#6750A4" opacity="0.55"/>
<path d="M112 137L82 166L112 195" stroke="#6750A4" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M188 137L218 166L188 195" stroke="#6750A4" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M169 126L133 206" stroke="#6750A4" stroke-width="12" stroke-linecap="round" opacity="0.62"/>
</svg>

Before

Width:  |  Height:  |  Size: 733 B

-5
View File
@@ -1,5 +0,0 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M89 133C123 102 177 102 211 133" stroke="#6750A4" stroke-width="18" stroke-linecap="round"/>
<path d="M119 172C136 157 164 157 181 172" stroke="#6750A4" stroke-width="18" stroke-linecap="round" opacity="0.62"/>
<circle cx="150" cy="210" r="12" fill="#6750A4"/>
</svg>

Before

Width:  |  Height:  |  Size: 383 B

+13
View File
@@ -0,0 +1,13 @@
YouTube, OpenAI, Google, GitHub, Gemini, Netflix, Prime Video, Spotify, TikTok
and bilibili icons: Simple Icons 13.0.0.
Source: https://github.com/simple-icons/simple-icons/tree/13.0.0/icons
License: CC0 1.0 Universal.
License text: https://github.com/simple-icons/simple-icons/blob/13.0.0/LICENSE.md
The Claude sun symbol is an original vector illustration for FlClash.
The Disney+ entry uses a neutral play-and-plus glyph drawn for FlClash, because
Simple Icons 13.0.0 carries no Disney+ icon; it is a generic streaming mark, not
a reproduction of any Disney brand asset.
Brand names and marks belong to their respective owners.
+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bilibili</title><path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m12 2-.2 6.8M17 3.4l-3 6.2M21 7l-6.3 3.4M22 12l-7 .2M20.6 17l-6.2-3M17 21l-3.4-6.3M12 22l-.2-7M7 20.6l3-6.2M3 17l6.3-3.4M2 12l7-.2M3.4 7l6.2 3M7 3l3.4 6.3"/></g><path d="m12 8 3 2 1 3-3 3-3-1-2-3 1-3z"/></svg>

After

Width:  |  Height:  |  Size: 356 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Disney+</title><path d="M3.6 4.2h10.8a2.4 2.4 0 0 1 2.4 2.4v10.8a2.4 2.4 0 0 1-2.4 2.4H3.6a2.4 2.4 0 0 1-2.4-2.4V6.6a2.4 2.4 0 0 1 2.4-2.4Zm3 4.2v7.2l6-3.6-6-3.6ZM19.2 8.4h1.8v2.7h2.7v1.8h-2.7v2.7h-1.8v-2.7h-2.7v-1.8h2.7V8.4Z"/></svg>

After

Width:  |  Height:  |  Size: 302 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google Gemini</title><path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/></svg>

After

Width:  |  Height:  |  Size: 401 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>

After

Width:  |  Height:  |  Size: 822 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google</title><path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"/></svg>

After

Width:  |  Height:  |  Size: 457 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Netflix</title><path d="M5.398 0v.006c3.028 8.556 5.37 15.175 8.348 23.596 2.344.058 4.85.398 4.854.398-2.8-7.924-5.923-16.747-8.487-24zm8.489 0v9.63L18.6 22.951c-.043-7.86-.004-15.913.002-22.95zM5.398 1.05V24c1.873-.225 2.81-.312 4.715-.398v-9.22z"/></svg>

After

Width:  |  Height:  |  Size: 335 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Spotify</title><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>

After

Width:  |  Height:  |  Size: 697 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>TikTok</title><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>

After

Width:  |  Height:  |  Size: 707 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>YouTube</title><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>

After

Width:  |  Height:  |  Size: 459 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Glyph: Material Symbols "bug_report" (Rounded, filled), Apache License 2.0 -->
<svg width="108" height="108" viewBox="100 -854 760 760" xmlns="http://www.w3.org/2000/svg">
<title>status_4</title>
<defs>
<linearGradient id="bug-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#F59E0B"/>
<stop offset="100%" stop-color="#EA580C"/>
</linearGradient>
</defs>
<path fill="url(#bug-grad)" d="M480-120q-65.37 0-121.18-31Q303-182 276-240h-86q-12.75 0-21.37-8.68-8.63-8.67-8.63-21.5 0-12.82 8.63-21.32 8.62-8.5 21.37-8.5h62q-7-26-7-52.67V-406h-56q-12.75 0-21.37-8.68-8.63-8.67-8.63-21.5 0-12.82 8.63-21.32 8.62-8.5 21.37-8.5h56q0-29 .5-57.5T254-580h-64q-12.75 0-21.37-8.68-8.63-8.67-8.63-21.5 0-12.82 8.63-21.32 8.62-8.5 21.37-8.5h90q14-28 37-49t51-35l-57-56q-8-8-8-19.79 0-11.78 8.42-20.35 7.72-7.86 19.65-7.86t19.93 8l74 74q27.67-10 56.34-10Q510-756 538-746l74-74q8-8 19.79-8 11.78 0 20.35 8.42 7.86 7.72 7.86 19.65T652-780l-56 56q28 14 49.4 35.67Q666.8-666.67 683-640h88q12.75 0 21.38 8.68 8.62 8.67 8.62 21.5 0 12.82-8.62 21.32-8.63 8.5-21.38 8.5h-65q9 28 8.5 56.67-.5 28.66-.5 57.33h57q12.75 0 21.38 8.68 8.62 8.67 8.62 21.5 0 12.82-8.62 21.32-8.63 8.5-21.38 8.5h-57q0 27 .5 53.5T708-300h63q12.75 0 21.38 8.68 8.62 8.67 8.62 21.5 0 12.82-8.62 21.32-8.63 8.5-21.38 8.5h-86q-26 59-82.45 89.5Q546.11-120 480-120Zm-50-200h100q12.75 0 21.38-8.68 8.62-8.67 8.62-21.5 0-12.82-8.62-21.32-8.63-8.5-21.38-8.5H430q-12.75 0-21.37 8.68-8.63 8.67-8.63 21.5 0 12.82 8.63 21.32 8.62 8.5 21.37 8.5Zm0-173h100q12.75 0 21.38-8.68 8.62-8.67 8.62-21.5 0-12.82-8.62-21.32-8.63-8.5-21.38-8.5H430q-12.75 0-21.37 8.68-8.63 8.67-8.63 21.5 0 12.82 8.63 21.32 8.62 8.5 21.37 8.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+27 -6
View File
@@ -55,11 +55,12 @@ var (
// configMu -> selectMu.
selectMu sync.Mutex
isInit atomic.Bool
isRunning atomic.Bool
isSuspended atomic.Bool
sdkVersion atomic.Int32
testURL atomic.Pointer[string]
isInit atomic.Bool
isRunning atomic.Bool
isSuspended atomic.Bool
healthChecksStale atomic.Bool
sdkVersion atomic.Int32
testURL atomic.Pointer[string]
delayTestSlots = make(chan struct{}, delayTestConcurrency)
@@ -290,7 +291,10 @@ func updateConfig(params *UpdateParams) error {
general.UnifiedDelay = *params.UnifiedDelay
adapter.UnifiedDelay.Store(general.UnifiedDelay)
}
// IPv6 belongs with mode: turning it on moves a direct dial to another exit address.
routeChanged := false
if params.Mode != nil {
routeChanged = routeChanged || general.Mode != *params.Mode
general.Mode = *params.Mode
tunnel.SetMode(general.Mode)
}
@@ -299,6 +303,7 @@ func updateConfig(params *UpdateParams) error {
log.SetLevel(general.LogLevel)
}
if params.IPv6 != nil {
routeChanged = routeChanged || general.IPv6 != *params.IPv6
general.IPv6 = *params.IPv6
resolver.DisableIPv6 = !general.IPv6
}
@@ -316,6 +321,9 @@ func updateConfig(params *UpdateParams) error {
updateListeners(currentConfig)
syncGeoUpdater(params.GeoAutoUpdate, params.GeoUpdateInterval)
if routeChanged {
bumpRouteEpoch()
}
return nil
}
@@ -365,12 +373,24 @@ func reconcileGeoUpdater() {
stopGeoUpdater()
}
// mihomo writes the NTP time with settimeofday, which Android's app seccomp
// policy answers by killing the process rather than with EPERM.
var systemTimeWritable = runtime.GOOS != "android"
func loadConfig(path string) (*config.Config, error) {
buf, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return executor.ParseWithBytes(buf)
cfg, err := executor.ParseWithBytes(buf)
if err != nil {
return nil, err
}
if cfg.NTP.WriteToSystem && !systemTimeWritable {
log.Warnln("ntp write-to-system ignored: Android does not let apps set the system time")
cfg.NTP.WriteToSystem = false
}
return cfg, nil
}
func applyConfig(params *SetupParams) error {
@@ -400,6 +420,7 @@ func applyConfig(params *SetupParams) error {
currentConfig = cfg
hub.ApplyConfig(cfg)
patchSelectGroup(params.SelectedMap)
bumpRouteEpoch()
updateListeners(cfg)
reconcileGeoUpdater()
return err
+27
View File
@@ -598,6 +598,33 @@ func TestLoadConfigReportsMalformedYaml(t *testing.T) {
}
}
func TestLoadConfigKeepsTheSystemTimeWriteOnlyWhereItIsAllowed(t *testing.T) {
previous := systemTimeWritable
t.Cleanup(func() { systemTimeWritable = previous })
path := filepath.Join(t.TempDir(), "config.yaml")
profile := "ntp:\n enable: true\n write-to-system: true\n"
if err := os.WriteFile(path, []byte(profile), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
for _, writable := range []bool{true, false} {
systemTimeWritable = writable
cfg, err := loadConfig(path)
if err != nil {
t.Fatalf("loadConfig = %v", err)
}
if cfg.NTP.WriteToSystem != writable {
t.Errorf("writable %t: WriteToSystem = %t", writable, cfg.NTP.WriteToSystem)
}
if !cfg.NTP.Enable {
t.Errorf("writable %t: NTP was disabled, want only the system write dropped", writable)
}
}
}
func withSetupConfig(t *testing.T, apply func(*SetupParams) error) {
t.Helper()
previous := setupConfig
+65 -3
View File
@@ -1,6 +1,8 @@
package main
import (
"github.com/metacubex/http"
"github.com/metacubex/mihomo/adapter/provider"
P "github.com/metacubex/mihomo/component/process"
"github.com/metacubex/mihomo/constant"
@@ -55,17 +57,63 @@ type ChangeProxyParams struct {
ProxyName string `json:"proxy-name"`
}
type ChangeProxyResult struct {
Message string `json:"message"`
Changed bool `json:"changed"`
}
type RouteState struct {
CoreEpoch uint64 `json:"core-epoch"`
PicksVersion uint64 `json:"picks-version"`
Picks map[string]string `json:"picks"`
}
type TestDelayParams struct {
ProxyName string `json:"proxy-name"`
TestUrl string `json:"test-url"`
Timeout int64 `json:"timeout"`
}
type ProbeParams struct {
Url string `json:"url"`
ProxyName string `json:"proxy-name"`
Headers map[string]string `json:"headers"`
Timeout int64 `json:"timeout"`
MaxBody int64 `json:"max-body"`
}
type ProbeResult struct {
StatusCode int `json:"status-code"`
Delay int64 `json:"delay"`
Body string `json:"body"`
Url string `json:"url"`
Chains []string `json:"chains"`
Rule string `json:"rule"`
RulePayload string `json:"rule-payload"`
Error string `json:"error,omitempty"`
Message string `json:"message,omitempty"`
CoreEpoch uint64 `json:"core-epoch"`
PicksVersion uint64 `json:"picks-version"`
// Unexported, so it never reaches Dart: only the in-core checks read
// response headers, and ProbeResult is the shape the Dart side decodes.
header http.Header
}
type Traffic struct {
Up int64 `json:"up"`
Down int64 `json:"down"`
}
type MemoryStats struct {
Rss uint64 `json:"rss"`
HeapInuse uint64 `json:"heapInuse"`
HeapIdle uint64 `json:"heapIdle"`
StackInuse uint64 `json:"stackInuse"`
RuntimeOther uint64 `json:"runtimeOther"`
}
type ExternalProvider struct {
Name string `json:"name"`
Type string `json:"type"`
@@ -77,8 +125,13 @@ type ExternalProvider struct {
}
type ProxiesData struct {
Proxies map[string]constant.Proxy `json:"proxies"`
All []string `json:"all"`
Proxies map[string]any `json:"proxies"`
All []string `json:"all"`
}
type nodeView struct {
Name string `json:"name"`
Type string `json:"type"`
}
const (
@@ -88,6 +141,7 @@ const (
forceGcMethod CoreMethod = "forceGc"
shutdownMethod CoreMethod = "shutdown"
validateConfigMethod CoreMethod = "validateConfig"
validateProxiesMethod CoreMethod = "validateProxies"
updateConfigMethod CoreMethod = "updateConfig"
getProxiesMethod CoreMethod = "getProxies"
changeProxyMethod CoreMethod = "changeProxy"
@@ -95,13 +149,17 @@ const (
getTotalTrafficMethod CoreMethod = "getTotalTraffic"
resetTrafficMethod CoreMethod = "resetTraffic"
asyncTestDelayMethod CoreMethod = "asyncTestDelay"
probeMethod CoreMethod = "probe"
outboundIpMethod CoreMethod = "outboundIp"
serviceCheckMethod CoreMethod = "serviceCheck"
getConnectionsMethod CoreMethod = "getConnections"
getConnectionCountMethod CoreMethod = "getConnectionCount"
closeConnectionsMethod CoreMethod = "closeConnections"
resetConnectionsMethod CoreMethod = "resetConnections"
closeConnectionMethod CoreMethod = "closeConnection"
getExternalProvidersMethod CoreMethod = "getExternalProviders"
getExternalProviderMethod CoreMethod = "getExternalProvider"
getMemoryMethod CoreMethod = "getMemory"
getMemoryStatsMethod CoreMethod = "getMemoryStats"
updateGeoDataMethod CoreMethod = "updateGeoData"
updateExternalProviderMethod CoreMethod = "updateExternalProvider"
sideLoadExternalProviderMethod CoreMethod = "sideLoadExternalProvider"
@@ -114,6 +172,7 @@ const (
setupConfigMethod CoreMethod = "setupConfig"
getConfigMethod CoreMethod = "getConfig"
clearEffectMethod CoreMethod = "clearEffect"
watchRouteMethod CoreMethod = "watchRoute"
)
type CoreMethod string
@@ -135,8 +194,11 @@ const (
LogMessage MessageType = "log"
DelayMessage MessageType = "delay"
RequestMessage MessageType = "request"
DnsMessage MessageType = "dns"
LoadedMessage MessageType = "loaded"
GeoUpdateMessage MessageType = "geoUpdate"
// Named after the Dart enum value, which is how CoreEvent.fromJson decodes it.
RouteChangedMessage MessageType = "routeChanged"
)
type GeoUpdateStatus struct {
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"strings"
"time"
"github.com/metacubex/mihomo/dns"
D "github.com/miekg/dns"
)
type DnsQuery struct {
Domain string `json:"domain"`
Type string `json:"type"`
Initiator string `json:"initiator"`
Upstream string `json:"upstream,omitempty"`
Cached bool `json:"cached,omitempty"`
Answers []string `json:"answers"`
Rcode string `json:"rcode,omitempty"`
Error string `json:"error,omitempty"`
Delay int64 `json:"delay"`
Time time.Time `json:"time"`
}
func newDnsQuery(record dns.QueryRecord) DnsQuery {
query := DnsQuery{
Domain: strings.TrimSuffix(record.Question.Name, "."),
Type: D.Type(record.Question.Qtype).String(),
Initiator: record.Initiator,
Upstream: record.Upstream,
Cached: record.Cached,
Answers: []string{},
Delay: time.Since(record.Start).Milliseconds(),
Time: record.Start,
}
if record.Err != nil {
query.Error = record.Err.Error()
}
if record.Msg == nil {
return query
}
query.Rcode = D.RcodeToString[record.Msg.Rcode]
for _, rr := range record.Msg.Answer {
query.Answers = append(query.Answers, dnsAnswerValue(rr))
}
return query
}
func dnsAnswerValue(rr D.RR) string {
switch record := rr.(type) {
case *D.A:
return record.A.String()
case *D.AAAA:
return record.AAAA.String()
case *D.CNAME:
return strings.TrimSuffix(record.Target, ".")
default:
return strings.TrimSpace(strings.TrimPrefix(rr.String(), rr.Header().String()))
}
}
+108
View File
@@ -0,0 +1,108 @@
package main
import (
"encoding/json"
"errors"
"net"
"reflect"
"testing"
"time"
"github.com/metacubex/mihomo/component/resolver"
"github.com/metacubex/mihomo/dns"
D "github.com/miekg/dns"
)
func dnsQuestion(name string, qtype uint16) D.Question {
return D.Question{Name: D.Fqdn(name), Qtype: qtype, Qclass: D.ClassINET}
}
func TestNewDnsQueryFlattensAnswers(t *testing.T) {
request := new(D.Msg)
request.SetQuestion("www.example.com.", D.TypeA)
resp := new(D.Msg)
resp.SetReply(request)
header := func(rrtype uint16) D.RR_Header {
return D.RR_Header{Name: "www.example.com.", Rrtype: rrtype, Class: D.ClassINET, Ttl: 60}
}
resp.Answer = []D.RR{
&D.CNAME{Hdr: header(D.TypeCNAME), Target: "edge.example.net."},
&D.A{Hdr: header(D.TypeA), A: net.ParseIP("93.184.216.34").To4()},
&D.AAAA{Hdr: header(D.TypeAAAA), AAAA: net.ParseIP("2606:2800:220:1::248")},
&D.TXT{Hdr: header(D.TypeTXT), Txt: []string{"v=spf1 -all"}},
}
start := time.Now()
query := newDnsQuery(dns.QueryRecord{
Question: request.Question[0],
Msg: resp,
Initiator: resolver.InitiatorApp,
Upstream: "tls://1.1.1.1:853",
Cached: true,
Start: start,
})
if query.Domain != "www.example.com" || query.Type != "A" {
t.Fatalf("query = %+v", query)
}
if query.Initiator != "app" || query.Upstream != "tls://1.1.1.1:853" || !query.Cached {
t.Fatalf("initiator = %q, upstream = %q, cached = %v", query.Initiator, query.Upstream, query.Cached)
}
if query.Rcode != "NOERROR" || query.Error != "" {
t.Fatalf("rcode = %q, error = %q", query.Rcode, query.Error)
}
want := []string{"edge.example.net", "93.184.216.34", "2606:2800:220:1::248", `"v=spf1 -all"`}
if !reflect.DeepEqual(query.Answers, want) {
t.Fatalf("answers = %q, want %q", query.Answers, want)
}
if !query.Time.Equal(start) {
t.Fatalf("time = %v, want %v", query.Time, start)
}
}
func TestNewDnsQueryKeepsTheFailure(t *testing.T) {
query := newDnsQuery(dns.QueryRecord{
Question: dnsQuestion("missing.test", D.TypeAAAA),
Initiator: resolver.InitiatorDirect,
Start: time.Now(),
Err: errors.New("i/o timeout"),
})
if query.Error != "i/o timeout" || query.Rcode != "" {
t.Fatalf("error = %q, rcode = %q", query.Error, query.Rcode)
}
data, err := json.Marshal(query)
if err != nil {
t.Fatal(err)
}
var decoded map[string]any
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatal(err)
}
if answers, ok := decoded["answers"].([]any); !ok || len(answers) != 0 {
t.Fatalf("answers = %v, want an empty list", decoded["answers"])
}
for _, key := range []string{"upstream", "cached"} {
if _, ok := decoded[key]; ok {
t.Fatalf("%s = %v, want it omitted", key, decoded[key])
}
}
if decoded["initiator"] != "direct" {
t.Fatalf("initiator = %v, want direct", decoded["initiator"])
}
}
func TestNewDnsQueryNamesUnknownTypes(t *testing.T) {
resp := new(D.Msg)
resp.Rcode = D.RcodeNameError
query := newDnsQuery(dns.QueryRecord{
Question: dnsQuestion("example.com", 65280),
Msg: resp,
Start: time.Now(),
})
if query.Type != "TYPE65280" || query.Rcode != "NXDOMAIN" {
t.Fatalf("type = %q, rcode = %q", query.Type, query.Rcode)
}
}
+15 -13
View File
@@ -6,7 +6,9 @@ replace github.com/metacubex/mihomo => ./Clash.Meta
require (
github.com/Microsoft/go-winio v0.6.2
github.com/metacubex/http v0.1.7
github.com/metacubex/mihomo v0.0.0-00010101000000-000000000000
github.com/miekg/dns v1.1.63
)
require (
@@ -22,7 +24,8 @@ require (
github.com/coreos/go-iptables v0.8.0 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dunglas/httpsfv v1.0.2 // indirect
github.com/enfein/mieru/v3 v3.35.0 // indirect
github.com/easytier/easytier/easytier-go v0.0.0-20260910071355-3d0c9c3ca5e2 // indirect
github.com/enfein/mieru/v3 v3.37.0 // indirect
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 // indirect
github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 // indirect
github.com/ericlagergren/siv v0.0.0-20220507050439-0b757b3aa5f1 // indirect
@@ -50,7 +53,7 @@ require (
github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 // indirect
github.com/metacubex/amneziawg-go v0.0.0-20260816073447-736a78668832 // indirect
github.com/metacubex/amneziawg-go v0.0.0-20260908071407-0c1c6f40ecd7 // indirect
github.com/metacubex/ascon v0.1.0 // indirect
github.com/metacubex/bart v0.29.0 // indirect
github.com/metacubex/bbolt v0.0.0-20260706163408-d4ec34ad7c48 // indirect
@@ -62,16 +65,15 @@ require (
github.com/metacubex/edwards25519 v1.2.0 // indirect
github.com/metacubex/fswatch v0.1.1 // indirect
github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 // indirect
github.com/metacubex/gvisor v0.0.0-20260810011720-3cc44cf9ac22 // indirect
github.com/metacubex/gvisor v0.0.0-20260826100401-79317d808312 // indirect
github.com/metacubex/hkdf v0.1.0 // indirect
github.com/metacubex/hpke v0.1.0 // indirect
github.com/metacubex/http v0.1.7 // indirect
github.com/metacubex/jls-quic-go v0.0.0-20260727080412-732f2fc9a34d // indirect
github.com/metacubex/jls-tls v0.0.0-20260723084315-67adc0e2f796 // indirect
github.com/metacubex/jsonv2 v0.0.0-20260721082349-16b4998c8f89 // indirect
github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 // indirect
github.com/metacubex/mhurl v0.1.0 // indirect
github.com/metacubex/mipstack v0.0.0-20260816065001-b7038299fe13 // indirect
github.com/metacubex/mipstack v0.0.0-20260910230046-ba762df4c91d // indirect
github.com/metacubex/mlkem v0.1.0 // indirect
github.com/metacubex/nftables v0.0.0-20260426003805-208c2c1ba2cb // indirect
github.com/metacubex/qpack v0.6.0 // indirect
@@ -81,23 +83,23 @@ require (
github.com/metacubex/sevenzip v1.6.4 // indirect
github.com/metacubex/sing v0.5.7 // indirect
github.com/metacubex/sing-mux v0.3.10 // indirect
github.com/metacubex/sing-quic v0.0.0-20260726014900-38b0e9295f51 // indirect
github.com/metacubex/sing-shadowsocks v0.2.12 // indirect
github.com/metacubex/sing-shadowsocks2 v0.2.7 // indirect
github.com/metacubex/sing-tun v0.4.22 // indirect
github.com/metacubex/sing-quic v0.0.0-20260904234848-1c242664697a // indirect
github.com/metacubex/sing-shadowsocks v0.2.13 // indirect
github.com/metacubex/sing-shadowsocks2 v0.2.8 // indirect
github.com/metacubex/sing-tun v0.4.24 // indirect
github.com/metacubex/sing-vmess v0.2.5 // indirect
github.com/metacubex/sing-wireguard v0.0.0-20260810013230-110eac03c3f0 // indirect
github.com/metacubex/sing-wireguard v0.0.0-20260826105301-c3ae17d19f9e // indirect
github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 // indirect
github.com/metacubex/ssh v0.1.0 // indirect
github.com/metacubex/tailscale v0.0.0-20260807072706-a4fb5feabcbb // indirect
github.com/metacubex/tailscale v0.0.0-20260821153257-ff0ecd818181 // indirect
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260725073821-e61ab99cede2 // indirect
github.com/metacubex/tfo-go v0.0.0-20260623020846-376a77860b8c // indirect
github.com/metacubex/tls v0.1.8 // indirect
github.com/metacubex/utls v1.8.7 // indirect
github.com/metacubex/wazero v0.0.0-20260628025728-9ae6bdcf2a7d // indirect
github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f // indirect
github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49 // indirect
github.com/metacubex/zerotier-go v0.0.0-20260813124750-13fa6f45da5f // indirect
github.com/miekg/dns v1.1.63 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/mroth/weightedrand/v2 v2.1.0 // indirect
github.com/oasisprotocol/deoxysii v0.0.0-20220228165953-2091330c22b7 // indirect
@@ -127,6 +129,7 @@ require (
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
gitlab.com/go-extension/aes-ccm v0.0.0-20230221065045-e58665ef23c7 // indirect
gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.33.0 // indirect
@@ -140,5 +143,4 @@ require (
golang.org/x/time v0.10.0 // indirect
golang.org/x/tools v0.24.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+28 -27
View File
@@ -26,14 +26,15 @@ github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3C
github.com/coreos/go-iptables v0.8.0 h1:MPc2P89IhuVpLI7ETL/2tx3XZ61VeICZjYqDEgNsPRc=
github.com/coreos/go-iptables v0.8.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0=
github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
github.com/enfein/mieru/v3 v3.35.0 h1:AKQT6l9R6xcYaUocpfjqE/vWEvYMxpl+071bhfDXkMY=
github.com/enfein/mieru/v3 v3.35.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
github.com/easytier/easytier/easytier-go v0.0.0-20260910071355-3d0c9c3ca5e2 h1:2/9iRUcMR3QPrT2XFf/i7QV5YoSHPFjkgBM5tP8xbcc=
github.com/easytier/easytier/easytier-go v0.0.0-20260910071355-3d0c9c3ca5e2/go.mod h1:fCEi5+K2yt+sPDONSWO1uNpOifrwPgFGvm3J0YrFRvk=
github.com/enfein/mieru/v3 v3.37.0 h1:DKBDeBmlbpNKiOXtKXqxJo/BF+KvJKnbChn6fryeyHg=
github.com/enfein/mieru/v3 v3.37.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 h1:kXYqH/sL8dS/FdoFjr12ePjnLPorPo2FsnrHNuXSDyo=
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358/go.mod h1:hkIFzoiIPZYxdFOOLyDho59b7SrDfo+w3h+yWdlg45I=
github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 h1:8j2RH289RJplhA6WfdaPqzg1MjH2K8wX5e0uhAxrw2g=
@@ -102,8 +103,8 @@ github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 h1:LqWr0vb9zDNuQS+jJd4fnRYk/SEI7KJ7TDe/L4WFK48=
github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78/go.mod h1:BTBG/iVY7rg3qq5WdVCg0GFk58CSvCDSbjy8I7kEx/c=
github.com/metacubex/amneziawg-go v0.0.0-20260816073447-736a78668832 h1:mAIVFc8Z9jziImzNl5bXUBVU2bTEmP2sXfosn/6YxPs=
github.com/metacubex/amneziawg-go v0.0.0-20260816073447-736a78668832/go.mod h1:MsM/5czONyXMJ3PRr5DbQ4O/BxzAnJWOIcJdLzW6qHY=
github.com/metacubex/amneziawg-go v0.0.0-20260908071407-0c1c6f40ecd7 h1:OJa/EzqUNScl8X4UCUfG5DDqRdEds89k2XwzBZrnqcE=
github.com/metacubex/amneziawg-go v0.0.0-20260908071407-0c1c6f40ecd7/go.mod h1:MsM/5czONyXMJ3PRr5DbQ4O/BxzAnJWOIcJdLzW6qHY=
github.com/metacubex/ascon v0.1.0 h1:6ZWxmXYszT1XXtwkf6nxfFhc/OTtQ9R3Vyj1jN32lGM=
github.com/metacubex/ascon v0.1.0/go.mod h1:eV5oim4cVPPdEL8/EYaTZ0iIKARH9pnhAK/fcT5Kacc=
github.com/metacubex/bart v0.29.0 h1:yVc/lLdDqn+7FShI0qUghStHl0/d3mn4EH/I5R5niKk=
@@ -126,8 +127,8 @@ github.com/metacubex/fswatch v0.1.1 h1:jqU7C/v+g0qc2RUFgmAOPoVvfl2BXXUXEumn6oQux
github.com/metacubex/fswatch v0.1.1/go.mod h1:czrTT7Zlbz7vWft8RQu9Qqh+JoX+Nnb+UabuyN1YsgI=
github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 h1:cjd4biTvOzK9ubNCCkQ+ldc4YSH/rILn53l/xGBFHHI=
github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759/go.mod h1:UHOv2xu+RIgLwpXca7TLrXleEd4oR3sPatW6IF8wU88=
github.com/metacubex/gvisor v0.0.0-20260810011720-3cc44cf9ac22 h1:C4d4/BA8WuQmJnSpjy0UN4vJvRMjU3133rxIYQgbPPs=
github.com/metacubex/gvisor v0.0.0-20260810011720-3cc44cf9ac22/go.mod h1:mBJW3UXUusd8ZYO5M6mig0uScIey9iIubgdIiQAk2ug=
github.com/metacubex/gvisor v0.0.0-20260826100401-79317d808312 h1:GDSN5oSkugLGImDSM0W4UCovF5FLpTa6vL1g2f2+1do=
github.com/metacubex/gvisor v0.0.0-20260826100401-79317d808312/go.mod h1:mBJW3UXUusd8ZYO5M6mig0uScIey9iIubgdIiQAk2ug=
github.com/metacubex/hkdf v0.1.0 h1:fPA6VzXK8cU1foc/TOmGCDmSa7pZbxlnqhl3RNsthaA=
github.com/metacubex/hkdf v0.1.0/go.mod h1:3seEfds3smgTAXqUGn+tgEJH3uXdsUjOiduG/2EtvZ4=
github.com/metacubex/hpke v0.1.0 h1:gu2jUNhraehWi0P/z5HX2md3d7L1FhPQE6/Q0E9r9xQ=
@@ -144,8 +145,8 @@ github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 h1:hJwCVlE3ojViC3
github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604/go.mod h1:lpmN3m269b3V5jFCWtffqBLS4U3QQoIid9ugtO+OhVc=
github.com/metacubex/mhurl v0.1.0 h1:ZdW4Zxe3j3uJ89gNytOazHu6kbHn5owutN/VfXOI8GE=
github.com/metacubex/mhurl v0.1.0/go.mod h1:2qpQImCbXoUs6GwJrjuEXKelPyoimsIXr07eNKZdS00=
github.com/metacubex/mipstack v0.0.0-20260816065001-b7038299fe13 h1:aOU4avd9u7uFivubzn+SjFmEDWAkGF+gvZ0ecEdeg64=
github.com/metacubex/mipstack v0.0.0-20260816065001-b7038299fe13/go.mod h1:+bbwALZI0pbi2auSG5A3ptdpV2DZ2eLObziXo+P7oj0=
github.com/metacubex/mipstack v0.0.0-20260910230046-ba762df4c91d h1:hc1OeKdo7YIcmTrzwlJLjNZ4EZDKRHi/Ntv7GdYs2YI=
github.com/metacubex/mipstack v0.0.0-20260910230046-ba762df4c91d/go.mod h1:+bbwALZI0pbi2auSG5A3ptdpV2DZ2eLObziXo+P7oj0=
github.com/metacubex/mlkem v0.1.0 h1:wFClitonSFcmipzzQvax75beLQU+D7JuC+VK1RzSL8I=
github.com/metacubex/mlkem v0.1.0/go.mod h1:amhaXZVeYNShuy9BILcR7P0gbeo/QLZsnqCdL8U2PDQ=
github.com/metacubex/nftables v0.0.0-20260426003805-208c2c1ba2cb h1:wk6mHYPURSUvWcUv72gNP79oiylFsscBSDPJ6ieV6Iw=
@@ -164,24 +165,24 @@ github.com/metacubex/sing v0.5.7 h1:8OC+fhKFSv/l9ehEhJRaZZAOuthfZo68SteBVLe8QqM=
github.com/metacubex/sing v0.5.7/go.mod h1:ypf0mjwlZm0sKdQSY+yQvmsbWa0hNPtkeqyRMGgoN+w=
github.com/metacubex/sing-mux v0.3.10 h1:r5CuZ/KuwFsEcRRwpLvzLncW4fDzNfmSEcBEWcy/+94=
github.com/metacubex/sing-mux v0.3.10/go.mod h1:8bT7ZKT3clRrJjYc/x5CRYibC1TX/bK73a3r3+2E+Fc=
github.com/metacubex/sing-quic v0.0.0-20260726014900-38b0e9295f51 h1:FpQj1XfYBIm4LAml+LDjvnfjDs20cSDsFmgjThOqmks=
github.com/metacubex/sing-quic v0.0.0-20260726014900-38b0e9295f51/go.mod h1:6ayFGfzzBE85csgQkM3gf4neFq6s0losHlPRSxY+nuk=
github.com/metacubex/sing-shadowsocks v0.2.12 h1:Wqzo8bYXrK5aWqxu/TjlTnYZzAKtKsaFQBdr6IHFaBE=
github.com/metacubex/sing-shadowsocks v0.2.12/go.mod h1:2e5EIaw0rxKrm1YTRmiMnDulwbGxH9hAFlrwQLQMQkU=
github.com/metacubex/sing-shadowsocks2 v0.2.7 h1:hSuuc0YpsfiqYqt1o+fP4m34BQz4e6wVj3PPBVhor3A=
github.com/metacubex/sing-shadowsocks2 v0.2.7/go.mod h1:vOEbfKC60txi0ca+yUlqEwOGc3Obl6cnSgx9Gf45KjE=
github.com/metacubex/sing-tun v0.4.22 h1:6ARRJ2BIFD1u4r/DTMNcxaNuGyimfXEeUyD4iFJRaZs=
github.com/metacubex/sing-tun v0.4.22/go.mod h1:9T0SN+FHvzRrn7hgl2kmXy5v8xzzLKW1hITButIPAco=
github.com/metacubex/sing-quic v0.0.0-20260904234848-1c242664697a h1:qZzQwtWDrPU1LXoMow0/iLHY3mb7uauS/mho8hYcenE=
github.com/metacubex/sing-quic v0.0.0-20260904234848-1c242664697a/go.mod h1:6ayFGfzzBE85csgQkM3gf4neFq6s0losHlPRSxY+nuk=
github.com/metacubex/sing-shadowsocks v0.2.13 h1:PKmInHy9+4nY3FLhmKcWdASUBo9WZ/KrSPWjNpGaUTQ=
github.com/metacubex/sing-shadowsocks v0.2.13/go.mod h1:2e5EIaw0rxKrm1YTRmiMnDulwbGxH9hAFlrwQLQMQkU=
github.com/metacubex/sing-shadowsocks2 v0.2.8 h1:6MoODu7BAcnwIC0w9muq+2LZVz01wWD07L5IRhOty9Q=
github.com/metacubex/sing-shadowsocks2 v0.2.8/go.mod h1:vOEbfKC60txi0ca+yUlqEwOGc3Obl6cnSgx9Gf45KjE=
github.com/metacubex/sing-tun v0.4.24 h1:2H/RhlwQxUgc1K8h46giwV5uTUovx+DhQGpVPlUB4Yo=
github.com/metacubex/sing-tun v0.4.24/go.mod h1:YLk0jq8ITecb0LlNKAXC+NTkYTYmvihQxFAR495LN8c=
github.com/metacubex/sing-vmess v0.2.5 h1:m9Zt5I27lB9fmLMZfism9sH2LcnAfShZfwSkf6/KJoE=
github.com/metacubex/sing-vmess v0.2.5/go.mod h1:AwtlzUgf8COe9tRYAKqWZ+leDH7p5U98a0ZUpYehl8Q=
github.com/metacubex/sing-wireguard v0.0.0-20260810013230-110eac03c3f0 h1:4g/ObGTfHaY2zXcFjKOUO9aAFk+yFjxsZfUPQPG6J8c=
github.com/metacubex/sing-wireguard v0.0.0-20260810013230-110eac03c3f0/go.mod h1:H27NH3IgI4qk1Wj8kS9pneNMJYEuxFskcQfkfVW9vnw=
github.com/metacubex/sing-wireguard v0.0.0-20260826105301-c3ae17d19f9e h1:KGKZt//rrELT/dEwgYcHCqNYEGr3a7uVutlZrzrZZWY=
github.com/metacubex/sing-wireguard v0.0.0-20260826105301-c3ae17d19f9e/go.mod h1:OiYj4hitKrN1YIrgGHXp6zKnc9asCspF+SrYlLN2hYQ=
github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 h1:DK2l6m2Fc85H2BhiAPgbJygiWhesPlfGmF+9Vw6ARdk=
github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141/go.mod h1:/yI4OiGOSn0SURhZdJF3CbtPg3nwK700bG8TZLMBvAg=
github.com/metacubex/ssh v0.1.0 h1:iGfr99qk/eMHzUnQ/0bTxXT8+8SWqLSHBWDHoAhngzw=
github.com/metacubex/ssh v0.1.0/go.mod h1:NUtl0d+/f2cG9ECEpMM8iCVOpmggQlC13oLeDUONDlU=
github.com/metacubex/tailscale v0.0.0-20260807072706-a4fb5feabcbb h1:EE4+ehlygfki5qJvkIYs6zJXhv7WFAX+4cDQi/b6cY8=
github.com/metacubex/tailscale v0.0.0-20260807072706-a4fb5feabcbb/go.mod h1:fMMYScJPYt8Ss3D4VhontA2uV2N9NPZbbXsPP6LgQeo=
github.com/metacubex/tailscale v0.0.0-20260821153257-ff0ecd818181 h1:hWsSSzuCHe+WvPV1zhoJ8gXTXbp/5nhpPKxtpqgIXu0=
github.com/metacubex/tailscale v0.0.0-20260821153257-ff0ecd818181/go.mod h1:fMMYScJPYt8Ss3D4VhontA2uV2N9NPZbbXsPP6LgQeo=
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260725073821-e61ab99cede2 h1:nKyLVJEU4jsLVx+rdqbDUbEKTX/Sdu161AeVoZC9tlY=
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260725073821-e61ab99cede2/go.mod h1:W00EeoPdd2jbV3+ZUa0m2b7FdKx5B1LFsXvEpy7cToQ=
github.com/metacubex/tfo-go v0.0.0-20260623020846-376a77860b8c h1:+2C1UshSLzaogLY/IDz1XhOLxjhs3rkhVHB4eCN9Zto=
@@ -190,6 +191,8 @@ github.com/metacubex/tls v0.1.8 h1:BHF2payjtxC7wdR3tnq8vl3FAgEQB6/pPjP2tJNTsP4=
github.com/metacubex/tls v0.1.8/go.mod h1:0XeVdL0cBw+8i5Hqy3lVeP9IyD/LFTq02ExvHM6rzEM=
github.com/metacubex/utls v1.8.7 h1:Cp+yWkNTFkSihETgGWq34hlVFds5HpYWVOR1xovUVTs=
github.com/metacubex/utls v1.8.7/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko=
github.com/metacubex/wazero v0.0.0-20260628025728-9ae6bdcf2a7d h1:UMFI+kdp0jA8s9tC7oul0pWeTW6s8lnm0dD5PT+RY14=
github.com/metacubex/wazero v0.0.0-20260628025728-9ae6bdcf2a7d/go.mod h1:p48xp436h1oGfoXuEnVONeDhTW+E2UpY94GAVxA62oI=
github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f h1:FGBPRb1zUabhPhDrlKEjQ9lgIwQ6cHL4x8M9lrERhbk=
github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f/go.mod h1:oPGcV994OGJedmmxrcK9+ni7jUEMGhR+uVQAdaduIP4=
github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49 h1:lhlqpYHopuTLx9xQt22kSA9HtnyTDmk5XjjQVCGHe2E=
@@ -217,7 +220,6 @@ github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcR
github.com/pires/go-proxyproto v0.8.0 h1:5unRmEAPbHXHuLjDg01CxJWf91cw3lKHc/0xzKpXEe0=
github.com/pires/go-proxyproto v0.8.0/go.mod h1:iknsfgnH8EkjrMeMyvfKByp9TiBZCKZM0jx2xmKqnVY=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e h1:dCWirM5F3wMY+cmRda/B1BiPsFtmzXqV9b0hLWtVBMs=
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e/go.mod h1:9leZcVcItj6m9/CfHY5Em/iBrCz7js8LcRQGTKEEv2M=
@@ -250,8 +252,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE=
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
@@ -287,6 +289,8 @@ gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec h1:FpfFs4EhNehiV
gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec/go.mod h1:BZ1RAoRPbCxum9Grlv5aeksu2H8BiKehBYooU2LFiOQ=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
@@ -333,10 +337,7 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
+179 -27
View File
@@ -3,7 +3,11 @@ package main
import (
"cmp"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"io"
"net"
"net/url"
"os"
@@ -14,10 +18,10 @@ import (
"strconv"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/metacubex/mihomo/adapter"
"github.com/metacubex/mihomo/adapter/outboundgroup"
"github.com/metacubex/mihomo/common/observable"
"github.com/metacubex/mihomo/common/utils"
"github.com/metacubex/mihomo/component/resolver"
@@ -25,6 +29,8 @@ import (
"github.com/metacubex/mihomo/config"
"github.com/metacubex/mihomo/constant"
"github.com/metacubex/mihomo/constant/features"
cp "github.com/metacubex/mihomo/constant/provider"
"github.com/metacubex/mihomo/dns"
"github.com/metacubex/mihomo/hub/executor"
"github.com/metacubex/mihomo/listener"
"github.com/metacubex/mihomo/log"
@@ -54,6 +60,7 @@ func handleStartListener() bool {
isRunning.Store(true)
updateListeners(currentConfig)
resolver.ResetConnection()
refreshRoute()
return true
}
@@ -81,6 +88,7 @@ func handleForceGC() {
func handleShutdown() bool {
handleStopLog()
stopRouteWatch()
configMu.Lock()
isRunning.Store(false)
@@ -106,6 +114,21 @@ func handleValidateConfig(path string) string {
return ""
}
// The per-proxy parser the config load runs; checks across proxies, such as
// duplicate names, are left to the load itself.
func handleValidateProxies(mappings []map[string]any) []string {
results := make([]string, len(mappings))
for i, mapping := range mappings {
proxy, err := adapter.ParseProxy(mapping)
if err != nil {
results[i] = err.Error()
continue
}
_ = proxy.Close()
}
return results
}
const globalProxyName = "GLOBAL"
func isProxyGroupType(adapterType constant.AdapterType) bool {
@@ -155,12 +178,35 @@ func handleGetProxies() ProxiesData {
return p.Type(), true
})
views := make(map[string]any, len(proxies))
for name, proxy := range proxies {
views[name] = proxyView(proxy)
}
return ProxiesData{
All: allNames,
Proxies: proxies,
Proxies: views,
}
}
// Proxy.MarshalJSON encodes each node twice, with history the host never reads.
func proxyView(proxy constant.Proxy) any {
node := nodeView{Name: proxy.Name(), Type: proxy.Type().String()}
if !isProxyGroupType(proxy.Type()) {
return node
}
data, err := proxy.Adapter().MarshalJSON()
view := map[string]any{}
if err == nil {
err = json.Unmarshal(data, &view)
}
if err != nil {
log.Warnln("[APP] encode group %s: %v", node.Name, err)
return node
}
view["name"] = node.Name
return view
}
var (
errGroupNotFound = errors.New("Not found group")
errGroupInvalidType = errors.New("Group has invalid proxy type")
@@ -171,7 +217,7 @@ func lookupProxy(name string) constant.Proxy {
return tunnel.AllProxies()[name]
}
func selectableGroup(groupName string) (outboundgroup.SelectAble, error) {
func selectableGroup(groupName string) (pickableGroup, error) {
group := lookupProxy(groupName)
if group == nil {
return nil, errGroupNotFound
@@ -180,29 +226,30 @@ func selectableGroup(groupName string) (outboundgroup.SelectAble, error) {
if !ok {
return nil, errGroupInvalidType
}
selector, ok := adapterProxy.ProxyAdapter.(outboundgroup.SelectAble)
selector, ok := adapterProxy.ProxyAdapter.(pickableGroup)
if !ok {
return nil, errGroupNotSelect
}
return selector, nil
}
func handleChangeProxy(params *ChangeProxyParams) string {
func handleChangeProxy(params *ChangeProxyParams) *ChangeProxyResult {
selectMu.Lock()
defer selectMu.Unlock()
selector, err := selectableGroup(params.GroupName)
if err != nil {
return err.Error()
return &ChangeProxyResult{Message: err.Error()}
}
before := selector.Now()
if params.ProxyName == "" {
selector.ForceSet(params.ProxyName)
return ""
} else if err := selector.Set(params.ProxyName); err != nil {
return &ChangeProxyResult{Message: err.Error()}
}
if err := selector.Set(params.ProxyName); err != nil {
return err.Error()
}
return ""
changed := selector.Now() != before
refreshRouteLocked(false)
return &ChangeProxyResult{Changed: changed}
}
func handleGetTraffic(onlyStatisticsProxy bool) Traffic {
@@ -307,6 +354,15 @@ func handleGetConnections() *statistic.Snapshot {
return statistic.DefaultManager.Snapshot()
}
func handleGetConnectionCount() int {
count := 0
statistic.DefaultManager.Range(func(statistic.Tracker) bool {
count++
return true
})
return count
}
func handleCloseConnections() bool {
statistic.DefaultManager.Range(func(c statistic.Tracker) bool {
_ = c.Close()
@@ -440,20 +496,65 @@ func handleUpdateGeoData(geoType string) string {
return ""
}
func providerRequestErrorCode(err error) string {
type providerRequestFailure struct {
code string
reason string
statusCode int
}
// The reason values are matched by lib/common/network_error.dart.
func classifyProviderRequestError(err error) (providerRequestFailure, bool) {
// mihomo reports a non-2xx fetch as errors.New(resp.Status).
message := err.Error()
if len(message) >= 4 && message[3] == ' ' {
status, parseErr := strconv.Atoi(message[:3])
if parseErr == nil && status >= 100 && status <= 599 {
return "request_bad_response"
return providerRequestFailure{
code: "request_bad_response",
statusCode: status,
}, true
}
}
var urlError *url.Error
var networkError net.Error
if errors.Is(err, context.DeadlineExceeded) ||
if reason := providerRequestFailureReason(err); reason != "" ||
errors.As(err, &urlError) ||
errors.As(err, &networkError) {
return "request_error"
return providerRequestFailure{code: "request_error", reason: reason}, true
}
return providerRequestFailure{}, false
}
func providerRequestFailureReason(err error) string {
var unknownAuthority x509.UnknownAuthorityError
var hostname x509.HostnameError
var invalidCertificate x509.CertificateInvalidError
var certificateVerification *tls.CertificateVerificationError
var recordHeader tls.RecordHeaderError
var alert tls.AlertError
var dnsError *net.DNSError
var networkError net.Error
var opError *net.OpError
switch {
case errors.As(err, &unknownAuthority),
errors.As(err, &hostname),
errors.As(err, &invalidCertificate),
errors.As(err, &certificateVerification),
errors.As(err, &recordHeader),
errors.As(err, &alert):
return "tls"
case errors.As(err, &dnsError), errors.Is(err, resolver.ErrIPNotFound):
return "dns"
case errors.Is(err, context.DeadlineExceeded),
errors.Is(err, os.ErrDeadlineExceeded),
errors.As(err, &networkError) && networkError.Timeout():
return "timeout"
case errors.As(err, &opError),
errors.Is(err, syscall.ECONNREFUSED),
errors.Is(err, syscall.ECONNRESET),
errors.Is(err, io.EOF),
errors.Is(err, io.ErrUnexpectedEOF):
return "connection"
}
return ""
}
@@ -466,6 +567,25 @@ func providerMethodError(code, providerName string, err error) *MethodError {
}
}
func providerRequestMethodError(
failure providerRequestFailure,
providerName string,
err error,
) *MethodError {
details := map[string]any{"providerName": providerName}
if failure.reason != "" {
details["reason"] = failure.reason
}
if failure.statusCode != 0 {
details["statusCode"] = failure.statusCode
}
return &MethodError{
Code: failure.code,
Message: err.Error(),
Details: details,
}
}
func handleUpdateExternalProvider(providerName string) *MethodError {
p, exist := lookupExternalProvider(providerName)
if !exist {
@@ -485,12 +605,12 @@ func handleUpdateExternalProvider(providerName string) *MethodError {
}
defer releaseUpdate(key)
if err := p.Update(); err != nil {
code := providerRequestErrorCode(err)
if code == "" {
code = "provider_update_error"
if failure, ok := classifyProviderRequestError(err); ok {
return providerRequestMethodError(failure, providerName, err)
}
return providerMethodError(code, providerName, err)
return providerMethodError("provider_update_error", providerName, err)
}
refreshRouteAfterUpdate(p)
return nil
}
@@ -515,9 +635,19 @@ func handleSideLoadExternalProvider(providerName string, data []byte) *MethodErr
if err := sideUpdateExternalProvider(p, data); err != nil {
return providerMethodError("provider_update_error", providerName, err)
}
refreshRouteAfterUpdate(p)
return nil
}
// A rule set moves where a rule-routed probe goes without touching any pick.
func refreshRouteAfterUpdate(p cp.Provider) {
if p.Type() == cp.Rule {
bumpRouteEpoch()
return
}
refreshRoute()
}
// defaultRefreshHealthChecks re-probes every proxy provider off the calling
// thread. Providers coalesce concurrent checks internally, so an extra call
// costs nothing when one is already running.
@@ -532,9 +662,10 @@ func defaultRefreshHealthChecks() {
var refreshHealthChecks = defaultRefreshHealthChecks
func handleSuspend(suspended bool) bool {
wasSuspended := isSuspended.Swap(suspended)
func handleSuspend(suspended, interactive bool) bool {
isSuspended.Store(suspended)
if suspended {
healthChecksStale.Store(true)
tunnel.OnSuspend()
return true
}
@@ -544,10 +675,11 @@ func handleSuspend(suspended bool) bool {
// network at all, so coming back means every proxy is marked dead and every
// delay reads Timeout. A lazy provider then skips its next tick because
// nothing touched it in the meantime, and the whole list stays wrong until
// the user tests by hand. Re-check now instead - but not while the
// listeners are stopped, since the service also resumes the core on its way
// down.
if wasSuspended && isRunning.Load() {
// the user tests by hand. Re-check once the screen is on: a maintenance
// window resumes the core too, and probing every node there spends the
// window's radio time on results nobody sees. Not while the listeners are
// stopped either, since the service also resumes the core on its way down.
if interactive && healthChecksStale.Swap(false) && isRunning.Load() {
refreshHealthChecks()
}
return true
@@ -620,8 +752,18 @@ func handleStopLog() {
}
}
func handleGetMemory() uint64 {
return statistic.DefaultManager.Memory()
// HeapIdle still counts spans the runtime has already handed back to the OS,
// so the retained-but-unused figure subtracts HeapReleased.
func handleGetMemoryStats() MemoryStats {
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
return MemoryStats{
Rss: statistic.DefaultManager.Memory(),
HeapInuse: stats.HeapInuse,
HeapIdle: stats.HeapIdle - stats.HeapReleased,
StackInuse: stats.StackInuse,
RuntimeOther: stats.MSpanInuse + stats.MCacheInuse + stats.BuckHashSys + stats.GCSys + stats.OtherSys,
}
}
func handleGetConfig(path string) (*config.RawConfig, error) {
@@ -695,11 +837,18 @@ func init() {
})
}
statistic.DefaultRequestNotify = func(c statistic.Tracker) {
notifyProbeRoute(c)
sendMessage(Message{
Type: RequestMessage,
Data: c,
})
}
dns.DefaultQueryNotify = func(record dns.QueryRecord) {
sendMessage(Message{
Type: DnsMessage,
Data: newDnsQuery(record),
})
}
executor.DefaultProviderLoadedHook = func(providerName string) {
scheduleReclaimOwnership()
sendMessage(Message{
@@ -713,6 +862,9 @@ func init() {
} else {
releaseGeoUpdateFromHook(geoType)
scheduleReclaimOwnership()
if !skipped && updateErr == nil {
bumpRouteEpoch()
}
}
status := GeoUpdateStatus{
Type: geoType,
+202 -14
View File
@@ -2,8 +2,12 @@ package main
import (
"context"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"slices"
@@ -11,6 +15,7 @@ import (
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
@@ -19,6 +24,7 @@ import (
"github.com/metacubex/mihomo/adapter/outboundgroup"
"github.com/metacubex/mihomo/adapter/provider"
"github.com/metacubex/mihomo/common/utils"
"github.com/metacubex/mihomo/component/resolver"
"github.com/metacubex/mihomo/config"
"github.com/metacubex/mihomo/constant"
cp "github.com/metacubex/mihomo/constant/provider"
@@ -110,18 +116,20 @@ func TestHandleSuspendRefreshesHealthChecksOnResume(t *testing.T) {
refreshHealthChecks = previous
isRunning.Store(previousRunning)
isSuspended.Store(false)
healthChecksStale.Store(false)
tunnel.OnRunning()
})
isSuspended.Store(false)
healthChecksStale.Store(false)
isRunning.Store(true)
handleSuspend(false)
handleSuspend(false, true)
if got := refreshes.Load(); got != 0 {
t.Errorf("refreshes = %d, want none: the device was never suspended", got)
}
handleSuspend(true)
handleSuspend(true, false)
if !isSuspended.Load() {
t.Error("handleSuspend(true) did not record the suspension")
}
@@ -129,7 +137,7 @@ func TestHandleSuspendRefreshesHealthChecksOnResume(t *testing.T) {
t.Errorf("refreshes = %d, want none while the device is still suspended", got)
}
handleSuspend(false)
handleSuspend(false, true)
if isSuspended.Load() {
t.Error("handleSuspend(false) did not clear the suspension")
}
@@ -137,7 +145,7 @@ func TestHandleSuspendRefreshesHealthChecksOnResume(t *testing.T) {
t.Errorf("refreshes = %d, want exactly one on resume", got)
}
handleSuspend(false)
handleSuspend(false, true)
if got := refreshes.Load(); got != 1 {
t.Errorf("refreshes = %d, want a redundant resume to change nothing", got)
}
@@ -145,13 +153,48 @@ func TestHandleSuspendRefreshesHealthChecksOnResume(t *testing.T) {
// The service resumes the core on its way down, and probing every node
// through a teardown only produces failures nobody asked for.
isRunning.Store(false)
handleSuspend(true)
handleSuspend(false)
handleSuspend(true, false)
handleSuspend(false, true)
if got := refreshes.Load(); got != 1 {
t.Errorf("refreshes = %d, want no probe while the listeners are stopped", got)
}
}
func TestHandleSuspendHoldsTheRefreshUntilTheScreenIsOn(t *testing.T) {
var refreshes atomic.Int32
previous := refreshHealthChecks
previousRunning := isRunning.Load()
refreshHealthChecks = func() { refreshes.Add(1) }
t.Cleanup(func() {
refreshHealthChecks = previous
isRunning.Store(previousRunning)
isSuspended.Store(false)
healthChecksStale.Store(false)
tunnel.OnRunning()
})
isSuspended.Store(false)
healthChecksStale.Store(false)
isRunning.Store(true)
handleSuspend(true, false)
handleSuspend(false, false)
if isSuspended.Load() {
t.Error("a maintenance window has to resume the core, or nothing syncs in it")
}
handleSuspend(true, false)
handleSuspend(false, false)
if got := refreshes.Load(); got != 0 {
t.Errorf("refreshes = %d, want none in maintenance windows with the screen off", got)
}
handleSuspend(false, true)
if got := refreshes.Load(); got != 1 {
t.Errorf("refreshes = %d, want the held refresh once the screen turns on", got)
}
}
func TestShouldPublishDelayDropsFailuresMeasuredInDoze(t *testing.T) {
t.Cleanup(func() { isSuspended.Store(false) })
@@ -371,6 +414,27 @@ func TestHandleValidateConfigAcceptsAValidFile(t *testing.T) {
}
}
func TestHandleValidateProxiesReportsEachProxyInPlace(t *testing.T) {
got := handleValidateProxies([]map[string]any{
{"name": "ok", "type": "socks5", "server": "127.0.0.1", "port": 1080},
{"name": "untyped", "server": "127.0.0.1", "port": 1080},
{"name": "unknown", "type": "nope"},
})
if len(got) != 3 {
t.Fatalf("handleValidateProxies returned %d results, want 3", len(got))
}
if got[0] != "" {
t.Errorf("valid proxy reported %q", got[0])
}
if !strings.Contains(got[1], "missing type") {
t.Errorf("untyped proxy reported %q, want missing type", got[1])
}
if !strings.Contains(got[2], "unsupport") {
t.Errorf("unknown type reported %q, want unsupported", got[2])
}
}
func TestHandleValidateConfigReportsAMissingFile(t *testing.T) {
got := handleValidateConfig(filepath.Join(t.TempDir(), "absent.yaml"))
@@ -475,7 +539,7 @@ func TestHandleChangeProxyDoesNotWaitOutAConfigApply(t *testing.T) {
answered := make(chan string, 1)
go func() {
answered <- handleChangeProxy(&ChangeProxyParams{GroupName: "absent", ProxyName: "node"})
answered <- handleChangeProxy(&ChangeProxyParams{GroupName: "absent", ProxyName: "node"}).Message
}()
select {
@@ -598,22 +662,75 @@ func (p *failingProxyProvider) Update() error {
return p.err
}
func TestProviderRequestErrorCode(t *testing.T) {
func TestClassifyProviderRequestError(t *testing.T) {
tests := []struct {
err error
want string
want providerRequestFailure
ok bool
}{
{err: errors.New("503 Service Unavailable"), want: "request_bad_response"},
{err: fmt.Errorf("fetch: %w", context.DeadlineExceeded), want: "request_error"},
{err: errors.New("proxy 0: unsupported type"), want: ""},
{
err: errors.New("503 Service Unavailable"),
want: providerRequestFailure{code: "request_bad_response", statusCode: 503},
ok: true,
},
{
err: fmt.Errorf("fetch: %w", context.DeadlineExceeded),
want: providerRequestFailure{code: "request_error", reason: "timeout"},
ok: true,
},
{
err: &url.Error{Op: "Get", URL: "https://sub.example", Err: &net.DNSError{Err: "no such host", Name: "sub.example"}},
want: providerRequestFailure{code: "request_error", reason: "dns"},
ok: true,
},
{
err: fmt.Errorf("dial: %w", resolver.ErrIPNotFound),
want: providerRequestFailure{code: "request_error", reason: "dns"},
ok: true,
},
{
err: &url.Error{Op: "Get", URL: "https://sub.example", Err: x509.UnknownAuthorityError{}},
want: providerRequestFailure{code: "request_error", reason: "tls"},
ok: true,
},
{
err: &url.Error{Op: "Get", URL: "https://sub.example", Err: &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}},
want: providerRequestFailure{code: "request_error", reason: "connection"},
ok: true,
},
{
err: &url.Error{Op: "Get", URL: "https://sub.example", Err: errors.New("unexpected")},
want: providerRequestFailure{code: "request_error"},
ok: true,
},
{err: errors.New("proxy 0: unsupported type")},
}
for _, test := range tests {
if got := providerRequestErrorCode(test.err); got != test.want {
t.Errorf("providerRequestErrorCode(%q) = %q, want %q", test.err, got, test.want)
got, ok := classifyProviderRequestError(test.err)
if got != test.want || ok != test.ok {
t.Errorf("classifyProviderRequestError(%q) = %+v, %v, want %+v, %v", test.err, got, ok, test.want, test.ok)
}
}
}
func TestUpdateExternalProviderReportsRequestFailureDetails(t *testing.T) {
const name = "subscription"
provider := &failingProxyProvider{
fakeProxyProvider: fakeProxyProvider{name: name, vehicle: cp.HTTP},
err: errors.New("403 Forbidden"),
}
withTunnelProviders(t, map[string]cp.ProxyProvider{name: provider}, nil)
methodError := handleUpdateExternalProvider(name)
if methodError == nil || methodError.Code != "request_bad_response" {
t.Fatalf("methodError = %+v, want request_bad_response", methodError)
}
details, ok := methodError.Details.(map[string]any)
if !ok || details["providerName"] != name || details["statusCode"] != 403 {
t.Errorf("details = %#v, want providerName %q and statusCode 403", methodError.Details, name)
}
}
func TestUpdateExternalProviderCategorizesFailure(t *testing.T) {
const name = "subscription"
provider := &failingProxyProvider{
@@ -823,6 +940,27 @@ func TestForceGCReleasesTheProxyCache(t *testing.T) {
}
}
func TestHandleGetMemoryStatsReportsALiveRuntime(t *testing.T) {
stats := handleGetMemoryStats()
if stats.HeapInuse == 0 {
t.Error("a running Go program always has heap in use; a zero means the runtime read was dropped")
}
encoded, err := json.Marshal(stats)
if err != nil {
t.Fatal(err)
}
var fields map[string]uint64
if err := json.Unmarshal(encoded, &fields); err != nil {
t.Fatal(err)
}
for _, key := range []string{"rss", "heapInuse", "heapIdle", "stackInuse", "runtimeOther"} {
if _, ok := fields[key]; !ok {
t.Errorf("memory stats lost the %q field the Dart model reads", key)
}
}
}
func TestAllProxiesFollowsAProviderUpdate(t *testing.T) {
provider := newCachingProvider("subscription", "node-a")
withTunnelProviders(t, map[string]cp.ProxyProvider{"subscription": provider}, nil)
@@ -878,3 +1016,53 @@ func TestHandleGetProxiesSeesAProviderUpdate(t *testing.T) {
t.Error("a proxy the refresh removed is still reported")
}
}
func TestProxyViewKeepsWhatTheHostReads(t *testing.T) {
encode := func(proxy constant.Proxy) map[string]any {
t.Helper()
data, err := json.Marshal(proxyView(proxy))
if err != nil {
t.Fatalf("marshal %s: %v", proxy.Name(), err)
}
view := map[string]any{}
if err := json.Unmarshal(data, &view); err != nil {
t.Fatalf("unmarshal %s: %v", proxy.Name(), err)
}
return view
}
node := encode(namedProxy("node-a"))
if want := map[string]any{"name": "node-a", "type": "Direct"}; fmt.Sprint(node) != fmt.Sprint(want) {
t.Errorf("node view = %v, want %v", node, want)
}
members := []constant.Proxy{namedProxy("node-a"), namedProxy("node-b")}
pd, err := provider.NewCompatibleProvider(
"group-provider", members, provider.NewHealthCheck(members, "", 0, 0, true, nil),
)
if err != nil {
t.Fatalf("NewCompatibleProvider: %v", err)
}
selector, err := outboundgroup.NewSelector(
outboundgroup.GroupCommonOption{Name: "group"},
outboundgroup.SelectorOption{},
namedProxy("COMPATIBLE"),
[]cp.ProxyProvider{pd},
)
if err != nil {
t.Fatalf("NewSelector: %v", err)
}
group := encode(adapter.NewProxy(selector))
if group["name"] != "group" || group["type"] != "Selector" || group["now"] != "node-a" {
t.Errorf("group view = %v, want its name, type and selection", group)
}
if fmt.Sprint(group["all"]) != "[node-a node-b]" {
t.Errorf("group members = %v, want [node-a node-b]", group["all"])
}
if _, exist := group["hidden"]; !exist {
t.Error("the host filters groups on hidden, so it has to be present")
}
if _, exist := group["history"]; exist {
t.Error("the view still carries delay history")
}
}
+16 -4
View File
@@ -285,7 +285,7 @@ func invokeMethod(callback unsafe.Pointer, paramsChar *C.char) {
newMethodResponse("", callback).failure("invalid_method_call", err.Error(), nil)
return
}
go handleMethodCall(call, newMethodResponse(call.ID, callback))
dispatchMethodCall(call, newMethodResponse(call.ID, callback))
}
//export startTUN
@@ -329,14 +329,26 @@ func quickSetup(callback unsafe.Pointer, initParamsChar *C.char, setupParamsChar
}()
}
// The listener goes away with the Flutter engine, which never gets to release
// the route watch it held, while the service and this core run on.
//
//export setEventListener
func setEventListener(listener unsafe.Pointer) {
eventListenerLock.Lock()
defer eventListenerLock.Unlock()
if eventListener != nil {
releaseObject(eventListener)
}
eventListener = listener
eventListenerLock.Unlock()
if listener == nil {
stopRouteWatch()
}
}
func hasEventListener() bool {
eventListenerLock.RLock()
defer eventListenerLock.RUnlock()
return eventListener != nil
}
//export getTotalTraffic
@@ -376,8 +388,8 @@ func stopTun() {
}
//export suspend
func suspend(suspended bool) {
handleSuspend(suspended)
func suspend(suspended, interactive bool) {
handleSuspend(suspended, interactive)
}
//export forceGC
+13 -1
View File
@@ -29,25 +29,37 @@ const (
stateMessageClass messageClass = iota
priorityMessageClass
bulkMessageClass
// Shares the bulk queue but never evicts from it: a lookup burst would
// otherwise push out the request events the connection list is built from.
yieldingBulkMessageClass
)
func classOfMessage(message Message) messageClass {
switch message.Type {
case LoadedMessage, GeoUpdateMessage:
case LoadedMessage, GeoUpdateMessage, RouteChangedMessage:
return stateMessageClass
case LogMessage, RequestMessage:
return bulkMessageClass
case DnsMessage:
return yieldingBulkMessageClass
default:
return priorityMessageClass
}
}
// Request and DNS events fire per connection and per query for as long as the
// Android service runs, engine or not; unheard, they are not worth encoding.
func sendMessage(message Message) {
if !hasEventListener() {
return
}
switch classOfMessage(message) {
case stateMessageClass:
enqueueState(stateMessageQueue, message)
case bulkMessageClass:
enqueueLatest(bulkMessageQueue, message)
case yieldingBulkMessageClass:
enqueueState(bulkMessageQueue, message)
default:
enqueueLatest(priorityMessageQueue, message)
}
+7 -5
View File
@@ -55,11 +55,13 @@ func runBatcherUntilDrained(t *testing.T, state, priority, bulk chan Message) *b
func TestClassOfMessageRoutesEachTier(t *testing.T) {
for messageType, want := range map[MessageType]messageClass{
LoadedMessage: stateMessageClass,
GeoUpdateMessage: stateMessageClass,
DelayMessage: priorityMessageClass,
LogMessage: bulkMessageClass,
RequestMessage: bulkMessageClass,
LoadedMessage: stateMessageClass,
GeoUpdateMessage: stateMessageClass,
RouteChangedMessage: stateMessageClass,
DelayMessage: priorityMessageClass,
LogMessage: bulkMessageClass,
RequestMessage: bulkMessageClass,
DnsMessage: yieldingBulkMessageClass,
} {
if got := classOfMessage(Message{Type: messageType}); got != want {
t.Errorf("classOfMessage(%s) = %d, want %d", messageType, got, want)
+42 -2
View File
@@ -12,6 +12,16 @@ type MethodCall struct {
ID string `json:"id,omitempty"`
Method CoreMethod `json:"method"`
Arguments json.RawMessage `json:"arguments"`
seq uint64
}
var methodCallSeq atomic.Uint64
// Each call runs on its own goroutine, so a handler that must honour the
// host's latest intent compares seq, stamped here in arrival order.
func dispatchMethodCall(call *MethodCall, response MethodResponse) {
call.seq = methodCallSeq.Add(1)
go handleMethodCall(call, response)
}
func (call MethodCall) decodeArguments(target any) error {
@@ -172,6 +182,9 @@ var methodHandlers = map[CoreMethod]methodHandler{
validateConfigMethod: withArguments(func(path *string, response MethodResponse) {
response.success(handleValidateConfig(*path))
}),
validateProxiesMethod: withArguments(func(mappings *[]map[string]any, response MethodResponse) {
response.success(handleValidateProxies(*mappings))
}),
updateConfigMethod: withArguments(func(params *UpdateParams, response MethodResponse) {
response.success(handleUpdateConfig(params))
}),
@@ -209,9 +222,27 @@ var methodHandlers = map[CoreMethod]methodHandler{
response.success(handleTestDelay(params))
})
}),
probeMethod: withArguments(func(params *ProbeParams, response MethodResponse) {
safeGo(response, func() {
response.success(handleProbe(params))
})
}),
outboundIpMethod: withArguments(func(params *OutboundIpParams, response MethodResponse) {
safeGo(response, func() {
response.success(handleOutboundIp(params))
})
}),
serviceCheckMethod: withArguments(func(params *ServiceCheckParams, response MethodResponse) {
safeGo(response, func() {
response.success(handleServiceCheck(params))
})
}),
getConnectionsMethod: withoutArguments(func(response MethodResponse) {
response.success(handleGetConnections())
}),
getConnectionCountMethod: withoutArguments(func(response MethodResponse) {
response.success(handleGetConnectionCount())
}),
closeConnectionsMethod: withoutArguments(func(response MethodResponse) {
response.success(handleCloseConnections())
}),
@@ -262,9 +293,9 @@ var methodHandlers = map[CoreMethod]methodHandler{
stopListenerMethod: withoutArguments(func(response MethodResponse) {
response.success(handleStopListener())
}),
getMemoryMethod: withoutArguments(func(response MethodResponse) {
getMemoryStatsMethod: withoutArguments(func(response MethodResponse) {
safeGo(response, func() {
response.success(handleGetMemory())
response.success(handleGetMemoryStats())
})
}),
clearEffectMethod: withArguments(func(profileId *int64, response MethodResponse) {
@@ -272,6 +303,15 @@ var methodHandlers = map[CoreMethod]methodHandler{
response.success(handleClearEffect(*profileId))
})
}),
watchRouteMethod: func(call *MethodCall, response MethodResponse) {
var watch bool
if !decodeMethodArguments(call, response, &watch) {
return
}
safeGo(response, func() {
response.success(handleWatchRoute(watch, call.seq))
})
},
}
func registerMethod(method CoreMethod, handler methodHandler) {
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"context"
"net/netip"
"strings"
"time"
"github.com/metacubex/http"
)
const (
// Racing every source at once would take every probe slot.
outboundIpConcurrency = 3
outboundIpStagger = 800 * time.Millisecond
outboundIpMaxBody = 4096
// Kept in step with browserUa in lib/common/constant.dart.
browserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
type OutboundIpParams struct {
ProxyName string `json:"proxy-name"`
Urls []string `json:"urls"`
Timeout int64 `json:"timeout"`
}
type OutboundIpResult struct {
Url string `json:"url"`
Body string `json:"body"`
Delay int64 `json:"delay"`
Chains []string `json:"chains"`
Error string `json:"error,omitempty"`
CoreEpoch uint64 `json:"core-epoch"`
PicksVersion uint64 `json:"picks-version"`
}
// handleOutboundIp takes the first usable answer and cancels the losers so they
// stop occupying probe slots. Url is the source that was asked, not where it
// redirected, and Body is raw: Dart owns the sources and their parsers.
func handleOutboundIp(params *OutboundIpParams) *OutboundIpResult {
epoch, picksVersion := routeStamp()
failure := func(kind string) *OutboundIpResult {
return &OutboundIpResult{Error: kind, CoreEpoch: epoch, PicksVersion: picksVersion}
}
if len(params.Urls) == 0 {
return failure(probeErrorFailed)
}
timeout := probeTimeout(params.Timeout)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
answers := make(chan *OutboundIpResult, len(params.Urls))
next := 0
inFlight := 0
start := func() {
if next >= len(params.Urls) || inFlight >= outboundIpConcurrency {
return
}
url := params.Urls[next]
next++
inFlight++
go func() {
answers <- probeOutboundIpSource(ctx, url, params.ProxyName, timeout)
}()
}
start()
stagger := time.NewTicker(outboundIpStagger)
defer stagger.Stop()
for inFlight > 0 {
select {
case answer := <-answers:
inFlight--
if answer != nil {
return answer
}
start()
case <-stagger.C:
start()
case <-ctx.Done():
return failure(probeErrorTimeout)
}
}
return failure(probeErrorFailed)
}
func probeOutboundIpSource(ctx context.Context, url string, proxyName string, timeout time.Duration) *OutboundIpResult {
result := runProbe(ctx, probeRequest{
method: http.MethodGet,
url: url,
proxyName: proxyName,
headers: map[string]string{"User-Agent": browserUserAgent},
timeout: timeout,
maxBody: outboundIpMaxBody,
})
if result == nil || result.Error != "" || result.StatusCode != http.StatusOK || !mentionsIp(result.Body) {
return nil
}
return &OutboundIpResult{
Url: url,
Body: result.Body,
Delay: result.Delay,
Chains: result.Chains,
CoreEpoch: result.CoreEpoch,
PicksVersion: result.PicksVersion,
}
}
// RFC 6598 space for carrier-grade NAT, which IsPrivate leaves out.
var sharedAddressSpace = netip.MustParsePrefix("100.64.0.0/10")
// A rate-limit or captcha page can still answer 200, and Dart cannot fall back
// once the race is decided, so a body without a public address must not win it.
func mentionsIp(body string) bool {
tokens := strings.FieldsFunc(body, func(r rune) bool {
return !(r == '.' || r == ':' || '0' <= r && r <= '9' || 'a' <= r && r <= 'z' || 'A' <= r && r <= 'Z')
})
for _, token := range tokens {
addr, err := netip.ParseAddr(token)
if err != nil {
continue
}
addr = addr.Unmap()
if addr.IsGlobalUnicast() && !addr.IsPrivate() && !sharedAddressSpace.Contains(addr) {
return true
}
}
return false
}
+147
View File
@@ -0,0 +1,147 @@
package main
import (
"net/http"
"sync/atomic"
"testing"
"time"
)
func TestHandleOutboundIpTakesTheFirstUsableAnswer(t *testing.T) {
withProbeProxies(t, "node-a")
slow := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(400 * time.Millisecond)
_, _ = w.Write([]byte("slow"))
})
empty := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
quick := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ip=203.0.113.7"))
})
result := handleOutboundIp(&OutboundIpParams{
ProxyName: "node-a",
Urls: []string{slow.URL, empty.URL, quick.URL},
Timeout: 4000,
})
if result.Error != "" {
t.Fatalf("error = %q, want none", result.Error)
}
if result.Body != "ip=203.0.113.7" {
t.Errorf("body = %q, want the quick source's", result.Body)
}
if result.Url != quick.URL {
t.Errorf("url = %q, want %q", result.Url, quick.URL)
}
}
func TestHandleOutboundIpSkipsAnAnswerWithoutAnAddress(t *testing.T) {
withProbeProxies(t, "node-a")
limited := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"error":"rate limited"}`))
})
later := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(200 * time.Millisecond)
_, _ = w.Write([]byte(`{"ip":"2001:db8::7","country":"JP"}`))
})
result := handleOutboundIp(&OutboundIpParams{
ProxyName: "node-a",
Urls: []string{limited.URL, later.URL},
Timeout: 4000,
})
if result.Url != later.URL {
t.Errorf("url = %q, want the source that named an address", result.Url)
}
}
func TestMentionsIpNeedsAWholeAddress(t *testing.T) {
cases := map[string]bool{
"fl=12f\nip=203.0.113.7\nts=1700000000.1": true,
`{"ip":"2001:db8::7","country":"JP"}`: true,
`{"ip":"::ffff:203.0.113.7"}`: true,
`a::after{content:""}`: false,
`div::before,li::marker{color:#fff}`: false,
`::-webkit-scrollbar{width:0}`: false,
`<a href="http://127.0.0.1/login">`: false,
}
for body, want := range cases {
if got := mentionsIp(body); got != want {
t.Errorf("mentionsIp(%q) = %v, want %v", body, got, want)
}
}
}
func TestMentionsIpSkipsLocalAddresses(t *testing.T) {
for _, body := range []string{
"ip=192.168.1.1",
`{"ip":"10.20.30.40"}`,
"ip=172.16.0.9",
"ip=100.64.12.34",
`{"ip":"fd00::1"}`,
`{"ip":"::ffff:192.168.1.1"}`,
} {
if mentionsIp(body) {
t.Errorf("mentionsIp(%q) = true, want false", body)
}
}
}
func TestHandleOutboundIpCancelsTheLosers(t *testing.T) {
withProbeProxies(t, "node-a")
var cancelled atomic.Bool
losing := probeServer(t, func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
cancelled.Store(true)
case <-time.After(3 * time.Second):
}
})
winning := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ip=203.0.113.9"))
})
result := handleOutboundIp(&OutboundIpParams{
ProxyName: "node-a",
Urls: []string{losing.URL, winning.URL},
Timeout: 5000,
})
if result.Body != "ip=203.0.113.9" {
t.Fatalf("body = %q, want the winning source's", result.Body)
}
deadline := time.Now().Add(2 * time.Second)
for !cancelled.Load() && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if !cancelled.Load() {
t.Error("the losing request kept running, want it cancelled once the race was won")
}
}
func TestHandleOutboundIpReportsWhenEverySourceFails(t *testing.T) {
withProbeProxies(t, "node-a")
broken := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
result := handleOutboundIp(&OutboundIpParams{
ProxyName: "node-a",
Urls: []string{broken.URL, broken.URL},
Timeout: 3000,
})
if result.Error != probeErrorFailed {
t.Errorf("error = %q, want %q", result.Error, probeErrorFailed)
}
}
func TestHandleOutboundIpRejectsAnEmptySourceList(t *testing.T) {
result := handleOutboundIp(&OutboundIpParams{ProxyName: "node-a", Timeout: 1000})
if result.Error != probeErrorFailed {
t.Errorf("error = %q, want %q", result.Error, probeErrorFailed)
}
}
+372
View File
@@ -0,0 +1,372 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"github.com/metacubex/http"
"io"
"net"
"sync"
"time"
N "github.com/metacubex/mihomo/common/net"
"github.com/metacubex/mihomo/component/ca"
"github.com/metacubex/mihomo/constant"
"github.com/metacubex/mihomo/tunnel"
"github.com/metacubex/mihomo/tunnel/statistic"
)
const (
probeConcurrency = 8
defaultProbeTimeout = 10 * time.Second
probeErrorTimeout = "timeout"
probeErrorFailed = "failed"
// A routed dial learns its outbound from the tracker the tunnel registers
// once the remote side is up. The request is held back until then, so the
// answer does not depend on the server keeping the connection open, but
// never longer than this: a sniffer waiting for client bytes gives up
// after one second, and past that the request goes out and the lookup is
// retried when the response arrives.
probeRouteWait = 2 * time.Second
probeTunnelPoll = 50 * time.Millisecond
probeScanChunk = 32 * 1024
probeScanOverlap = 256
)
var (
probeSlots = make(chan struct{}, probeConcurrency)
pendingProbeRoutes sync.Map
)
type probeRoute struct {
chains []string
rule string
rulePayload string
}
// probeDialer opens one connection per dial, either pinned to a proxy or
// routed through the tunnel like any inbound connection, and keeps the route
// of the most recent one, which after redirects is the one that produced the
// final response.
type probeDialer struct {
proxy constant.Proxy
mu sync.Mutex
last *routedConn
}
func (d *probeDialer) dial(ctx context.Context, _ string, address string) (net.Conn, error) {
metadata := &constant.Metadata{
NetWork: constant.TCP,
Type: constant.INNER,
DNSMode: constant.DNSNormal,
Process: constant.MihomoName,
}
if err := metadata.SetRemoteAddress(address); err != nil {
return nil, err
}
if d.proxy != nil {
conn, err := d.proxy.DialContext(ctx, metadata)
if err != nil {
return nil, err
}
d.remember(&routedConn{route: &probeRoute{chains: conn.Chains()}})
return conn, nil
}
if err := awaitTunnel(ctx); err != nil {
return nil, err
}
local, remote := N.Pipe()
routed := &routedConn{
Conn: local,
metadata: metadata,
ctx: ctx,
closed: make(chan struct{}),
ready: make(chan struct{}),
}
pendingProbeRoutes.Store(metadata, routed)
go tunnel.Tunnel.HandleTCPConn(&closeNotifier{Conn: remote, closed: routed.closed}, metadata)
d.remember(routed)
return routed, nil
}
// Applying a config suspends the tunnel before the route epoch moves, so a
// connection refused in that window would come back as a failure the host
// still takes for the current route.
func awaitTunnel(ctx context.Context) error {
for tunnel.Status() == tunnel.Suspend {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(probeTunnelPoll):
}
}
return nil
}
func (d *probeDialer) remember(conn *routedConn) {
d.mu.Lock()
defer d.mu.Unlock()
d.last = conn
}
func (d *probeDialer) route() *probeRoute {
d.mu.Lock()
last := d.last
d.mu.Unlock()
if last == nil {
return nil
}
return last.resolve()
}
type routedConn struct {
net.Conn
metadata *constant.Metadata
ctx context.Context
closed chan struct{}
ready chan struct{}
first sync.Once
mu sync.Mutex
route *probeRoute
}
func (c *routedConn) Close() error {
pendingProbeRoutes.CompareAndDelete(c.metadata, c)
return c.Conn.Close()
}
func (c *routedConn) Write(b []byte) (int, error) {
c.first.Do(c.awaitRoute)
return c.Conn.Write(b)
}
func (c *routedConn) awaitRoute() {
if c.ready == nil {
return
}
deadline := time.NewTimer(probeRouteWait)
defer deadline.Stop()
select {
case <-c.ready:
case <-deadline.C:
case <-c.closed:
case <-c.ctx.Done():
}
}
func (c *routedConn) resolve() *probeRoute {
c.mu.Lock()
defer c.mu.Unlock()
return c.route
}
func (c *routedConn) settle(route *probeRoute) {
c.mu.Lock()
c.route = route
c.mu.Unlock()
close(c.ready)
}
func notifyProbeRoute(tracker statistic.Tracker) {
info := tracker.Info()
pending, ok := pendingProbeRoutes.LoadAndDelete(info.Metadata)
if !ok {
return
}
pending.(*routedConn).settle(&probeRoute{
chains: append([]string(nil), info.Chain...),
rule: info.Rule,
rulePayload: info.RulePayload,
})
}
type closeNotifier struct {
net.Conn
once sync.Once
closed chan struct{}
}
func (c *closeNotifier) Close() error {
c.once.Do(func() { close(c.closed) })
return c.Conn.Close()
}
func probeTimeout(millis int64) time.Duration {
if millis <= 0 {
return defaultProbeTimeout
}
return time.Duration(millis) * time.Millisecond
}
func probeErrorKind(err error) string {
var netErr net.Error
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) {
return probeErrorTimeout
}
return probeErrorFailed
}
type probeRequest struct {
method string
url string
proxyName string
headers map[string]string
body []byte
timeout time.Duration
maxBody int64
// A followed redirect loses the Location that Netflix names its region in.
noRedirect bool
// until sees the latest chunk and probeScanOverlap bytes before it.
until func(tail string) bool
}
func handleProbe(params *ProbeParams) *ProbeResult {
return runProbe(context.Background(), probeRequest{
method: http.MethodGet,
url: params.Url,
proxyName: params.ProxyName,
headers: params.Headers,
timeout: probeTimeout(params.Timeout),
maxBody: params.MaxBody,
})
}
// runProbe returns nil when the request never got a slot before its deadline.
func runProbe(parent context.Context, req probeRequest) *ProbeResult {
result := &ProbeResult{Url: req.url, Chains: []string{}}
result.CoreEpoch, result.PicksVersion = routeStamp()
fail := func(err error) *ProbeResult {
result.Error = probeErrorKind(err)
result.Message = err.Error()
return result
}
dialer := &probeDialer{}
if req.proxyName != "" {
dialer.proxy = lookupProxy(req.proxyName)
if dialer.proxy == nil {
return fail(fmt.Errorf("proxy %q is not part of the applied config", req.proxyName))
}
}
tlsConfig, err := ca.GetTLSConfig(ca.Option{})
if err != nil {
return fail(err)
}
timeout := req.timeout
if timeout <= 0 {
timeout = defaultProbeTimeout
}
queueCtx, cancelQueue := context.WithTimeout(parent, timeout)
granted := acquireProbeSlot(queueCtx)
cancelQueue()
if !granted {
return nil
}
defer releaseProbeSlot()
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()
var body io.Reader
if len(req.body) > 0 {
body = bytes.NewReader(req.body)
}
method := req.method
if method == "" {
method = http.MethodGet
}
request, err := http.NewRequestWithContext(ctx, method, req.url, body)
if err != nil {
return fail(err)
}
for name, value := range req.headers {
request.Header.Set(name, value)
}
transport := &http.Transport{
DialContext: dialer.dial,
TLSClientConfig: tlsConfig,
TLSHandshakeTimeout: timeout,
MaxIdleConns: 1,
IdleConnTimeout: timeout,
}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport}
if req.noRedirect {
client.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
}
start := time.Now()
response, err := client.Do(request)
if err != nil {
return fail(err)
}
defer func() {
_ = response.Body.Close()
}()
result.Delay = time.Since(start).Milliseconds()
result.StatusCode = response.StatusCode
result.header = response.Header
result.Url = response.Request.URL.String()
if route := dialer.route(); route != nil {
result.Chains = route.chains
result.Rule = route.rule
result.RulePayload = route.rulePayload
}
if req.maxBody > 0 {
result.Body = readProbeBody(response.Body, req.maxBody, req.until)
}
return result
}
func readProbeBody(body io.Reader, maxBody int64, until func(string) bool) string {
limited := io.LimitReader(body, maxBody)
if until == nil {
payload, _ := io.ReadAll(limited)
return string(payload)
}
var buffer bytes.Buffer
chunk := make([]byte, probeScanChunk)
for {
n, err := limited.Read(chunk)
if n > 0 {
start := max(0, buffer.Len()-probeScanOverlap)
buffer.Write(chunk[:n])
if until(string(buffer.Bytes()[start:])) {
break
}
}
if err != nil {
break
}
}
return buffer.String()
}
func acquireProbeSlot(ctx context.Context) bool {
if ctx.Err() != nil {
return false
}
select {
case probeSlots <- struct{}{}:
return true
case <-ctx.Done():
return false
}
}
func releaseProbeSlot() {
<-probeSlots
}
+269
View File
@@ -0,0 +1,269 @@
package main
import (
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"github.com/metacubex/mihomo/constant"
"github.com/metacubex/mihomo/tunnel"
)
func probeServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
return server
}
func withProbeProxies(t *testing.T, names ...string) {
t.Helper()
proxies := make(map[string]constant.Proxy, len(names))
for _, name := range names {
proxies[name] = namedProxy(name)
}
tunnel.UpdateProxies(proxies, nil)
t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) })
}
func TestHandleProbePinsTheNamedProxy(t *testing.T) {
var userAgent string
server := probeServer(t, func(w http.ResponseWriter, r *http.Request) {
userAgent = r.UserAgent()
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("ip=203.0.113.7\nloc=US\n"))
})
withProbeProxies(t, "node-a")
result := handleProbe(&ProbeParams{
Url: server.URL + "/trace",
ProxyName: "node-a",
Headers: map[string]string{"User-Agent": "probe-test"},
Timeout: 2000,
MaxBody: 1024,
})
if result == nil {
t.Fatal("handleProbe = nil, want a result")
}
if result.Error != "" {
t.Fatalf("error = %q (%s), want none", result.Error, result.Message)
}
if result.StatusCode != http.StatusForbidden {
t.Errorf("status = %d, want 403", result.StatusCode)
}
if result.Body != "ip=203.0.113.7\nloc=US\n" {
t.Errorf("body = %q", result.Body)
}
if !slices.Equal(result.Chains, []string{"node-a"}) {
t.Errorf("chains = %v, want [node-a]", result.Chains)
}
if result.Url != server.URL+"/trace" {
t.Errorf("url = %q", result.Url)
}
if userAgent != "probe-test" {
t.Errorf("user agent = %q, want the header the caller passed", userAgent)
}
}
func TestHandleProbeFollowsRedirectsAndCapsTheBody(t *testing.T) {
server := probeServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.Redirect(w, r, "/final", http.StatusFound)
return
}
_, _ = w.Write([]byte(strings.Repeat("x", 100)))
})
withProbeProxies(t, "node-a")
result := handleProbe(&ProbeParams{Url: server.URL, ProxyName: "node-a", Timeout: 2000, MaxBody: 8})
if result == nil || result.Error != "" {
t.Fatalf("result = %+v, want a success", result)
}
if result.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200 after the redirect", result.StatusCode)
}
if result.Url != server.URL+"/final" {
t.Errorf("url = %q, want the redirect target", result.Url)
}
if len(result.Body) != 8 {
t.Errorf("body length = %d, want the cap", len(result.Body))
}
}
func TestReadProbeBodyStopsOnceTheMarkerArrives(t *testing.T) {
marker := "\"currentTerritory\":\"JP\""
page := strings.Repeat("x", probeScanChunk-10) + marker + strings.Repeat("y", 4*probeScanChunk)
var seen int
body := readProbeBody(strings.NewReader(page), int64(len(page)), func(tail string) bool {
seen++
return strings.Contains(tail, marker)
})
if !strings.Contains(body, marker) {
t.Fatalf("body lost the marker that straddled two chunks")
}
if len(body) >= len(page) || seen != 2 {
t.Errorf("read %d of %d bytes in %d chunks, want it to stop at the marker", len(body), len(page), seen)
}
}
func TestReadProbeBodyKeepsTheCapWithoutAMarker(t *testing.T) {
page := strings.Repeat("x", 3*probeScanChunk)
body := readProbeBody(strings.NewReader(page), probeScanChunk+5, func(string) bool { return false })
if len(body) != probeScanChunk+5 {
t.Errorf("body length = %d, want the cap", len(body))
}
}
func TestHandleProbeSkipsTheBodyWhenNotAsked(t *testing.T) {
server := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
withProbeProxies(t, "node-a")
result := handleProbe(&ProbeParams{Url: server.URL, ProxyName: "node-a", Timeout: 2000})
if result == nil || result.Error != "" {
t.Fatalf("result = %+v, want a success", result)
}
if result.StatusCode != http.StatusNoContent || result.Body != "" {
t.Errorf("status = %d body = %q, want 204 with no body", result.StatusCode, result.Body)
}
}
func TestHandleProbeReportsATimeout(t *testing.T) {
addr := blackHoleServer(t)
withProbeProxies(t, "node-a")
start := time.Now()
result := handleProbe(&ProbeParams{Url: "http://" + addr.String(), ProxyName: "node-a", Timeout: 200})
if result == nil {
t.Fatal("handleProbe = nil, want a result")
}
if result.Error != probeErrorTimeout {
t.Errorf("error = %q (%s), want timeout", result.Error, result.Message)
}
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Errorf("took %s, want the caller's budget", elapsed)
}
}
func TestHandleProbeReportsARefusedConnection(t *testing.T) {
server := probeServer(t, func(http.ResponseWriter, *http.Request) {})
url := server.URL
server.Close()
withProbeProxies(t, "node-a")
result := handleProbe(&ProbeParams{Url: url, ProxyName: "node-a", Timeout: 2000})
if result == nil || result.Error != probeErrorFailed {
t.Fatalf("result = %+v, want a failed probe", result)
}
if result.StatusCode != 0 || result.Message == "" {
t.Errorf("status = %d message = %q, want no status and a reason", result.StatusCode, result.Message)
}
}
func TestHandleProbeRejectsAnUnknownProxyWithoutWaitingForASlot(t *testing.T) {
for i := 0; i < probeConcurrency; i++ {
probeSlots <- struct{}{}
}
t.Cleanup(func() {
for i := 0; i < probeConcurrency; i++ {
<-probeSlots
}
})
done := make(chan *ProbeResult, 1)
go func() {
done <- handleProbe(&ProbeParams{Url: "http://example.invalid", ProxyName: "missing", Timeout: 1})
}()
select {
case result := <-done:
if result == nil || result.Error != probeErrorFailed {
t.Errorf("result = %+v, want a failed probe", result)
}
case <-time.After(time.Second):
t.Fatal("an unknown proxy queued behind a saturated probe semaphore")
}
}
func TestHandleProbeGivesUpQueueingOnTheDeadline(t *testing.T) {
for i := 0; i < probeConcurrency; i++ {
probeSlots <- struct{}{}
}
t.Cleanup(func() {
for i := 0; i < probeConcurrency; i++ {
<-probeSlots
}
})
withProbeProxies(t, "node-a")
if result := handleProbe(&ProbeParams{Url: "http://example.invalid", ProxyName: "node-a", Timeout: 50}); result != nil {
t.Errorf("result = %+v, want nil when no slot was granted", result)
}
}
func TestHandleProbeRoutesThroughTheTunnel(t *testing.T) {
server := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("routed"))
})
withProbeProxies(t, "DIRECT")
tunnel.OnRunning()
t.Cleanup(tunnel.OnSuspend)
result := handleProbe(&ProbeParams{Url: server.URL, Timeout: 2000, MaxBody: 64})
if result == nil || result.Error != "" {
t.Fatalf("result = %+v, want a success", result)
}
if result.StatusCode != http.StatusOK || result.Body != "routed" {
t.Errorf("status = %d body = %q", result.StatusCode, result.Body)
}
if !slices.Equal(result.Chains, []string{"DIRECT"}) {
t.Errorf("chains = %v, want the outbound the tunnel picked", result.Chains)
}
}
func TestHandleProbeWaitsOutASuspendedTunnel(t *testing.T) {
server := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("resumed"))
})
withProbeProxies(t, "DIRECT")
tunnel.OnSuspend()
t.Cleanup(tunnel.OnSuspend)
time.AfterFunc(150*time.Millisecond, tunnel.OnRunning)
result := handleProbe(&ProbeParams{Url: server.URL, Timeout: 2000, MaxBody: 64})
if result == nil || result.Error != "" {
t.Fatalf("result = %+v, want the answer once the tunnel runs again", result)
}
if result.Body != "resumed" {
t.Errorf("body = %q", result.Body)
}
}
func TestHandleProbeTimesOutWhileTheTunnelStaysSuspended(t *testing.T) {
server := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("unreachable"))
})
withProbeProxies(t, "DIRECT")
tunnel.OnSuspend()
result := handleProbe(&ProbeParams{Url: server.URL, Timeout: 200})
if result == nil || result.Error != probeErrorTimeout {
t.Fatalf("result = %+v, want a timeout", result)
}
}
+68
View File
@@ -0,0 +1,68 @@
package main
import "strings"
// Five bytes per entry, alpha-3 then alpha-2, sorted by alpha-3.
const iso3166Packed = "ABWAWAFGAFAGOAOAIAAIALAAXALBALANDADAREAEARGARARMAMASMASATAAQ" +
"ATFTFATGAGAUSAUAUTATAZEAZBDIBIBELBEBENBJBESBQBFABFBGDBDBGRBG" +
"BHRBHBHSBSBIHBABLMBLBLRBYBLZBZBMUBMBOLBOBRABRBRBBBBRNBNBTNBT" +
"BVTBVBWABWCAFCFCANCACCKCCCHECHCHLCLCHNCNCIVCICMRCMCODCDCOGCG" +
"COKCKCOLCOCOMKMCPVCVCRICRCUBCUCUWCWCXRCXCYMKYCYPCYCZECZDEUDE" +
"DJIDJDMADMDNKDKDOMDODZADZECUECEGYEGERIERESHEHESPESESTEEETHET" +
"FINFIFJIFJFLKFKFRAFRFROFOFSMFMGABGAGBRGBGEOGEGGYGGGHAGHGIBGI" +
"GINGNGLPGPGMBGMGNBGWGNQGQGRCGRGRDGDGRLGLGTMGTGUFGFGUMGUGUYGY" +
"HKGHKHMDHMHNDHNHRVHRHTIHTHUNHUIDNIDIMNIMINDINIOTIOIRLIEIRNIR" +
"IRQIQISLISISRILITAITJAMJMJEYJEJORJOJPNJPKAZKZKENKEKGZKGKHMKH" +
"KIRKIKNAKNKORKRKWTKWLAOLALBNLBLBRLRLBYLYLCALCLIELILKALKLSOLS" +
"LTULTLUXLULVALVMACMOMAFMFMARMAMCOMCMDAMDMDGMGMDVMVMEXMXMHLMH" +
"MKDMKMLIMLMLTMTMMRMMMNEMEMNGMNMNPMPMOZMZMRTMRMSRMSMTQMQMUSMU" +
"MWIMWMYSMYMYTYTNAMNANCLNCNERNENFKNFNGANGNICNINIUNUNLDNLNORNO" +
"NPLNPNRUNRNZLNZOMNOMPAKPKPANPAPCNPNPERPEPHLPHPLWPWPNGPGPOLPL" +
"PRIPRPRKKPPRTPTPRYPYPSEPSPYFPFQATQAREUREROURORUSRURWARWSAUSA" +
"SDNSDSENSNSGPSGSGSGSSHNSHSJMSJSLBSBSLESLSLVSVSMRSMSOMSOSPMPM" +
"SRBRSSSDSSSTPSTSURSRSVKSKSVNSISWESESWZSZSXMSXSYCSCSYRSYTCATC" +
"TCDTDTGOTGTHATHTJKTJTKLTKTKMTMTLSTLTONTOTTOTTTUNTNTURTRTUVTV" +
"TWNTWTZATZUGAUGUKRUAUMIUMURYUYUSAUSUZBUZVATVAVCTVCVENVEVGBVG" +
"VIRVIVNMVNVUTVUWLFWFWSMWSYEMYEZAFZAZMBZMZWEZW"
// normalizeRegion folds a country code to the alpha-2 form Dart renders a flag
// from, or "" when it is not one. Only Gemini reports alpha-3.
func normalizeRegion(code string) string {
code = strings.ToUpper(strings.TrimSpace(code))
switch len(code) {
case 2:
if isAsciiUpper(code) {
return code
}
return ""
case 3:
if !isAsciiUpper(code) {
return ""
}
low, high := 0, len(iso3166Packed)/5
for low < high {
mid := (low + high) / 2
entry := iso3166Packed[mid*5 : mid*5+3]
switch {
case entry < code:
low = mid + 1
case entry > code:
high = mid
default:
return iso3166Packed[mid*5+3 : mid*5+5]
}
}
return ""
default:
return ""
}
}
func isAsciiUpper(value string) bool {
for _, char := range value {
if char < 'A' || char > 'Z' {
return false
}
}
return true
}
+178
View File
@@ -0,0 +1,178 @@
package main
import (
"context"
"maps"
"sync"
"time"
"github.com/metacubex/mihomo/adapter"
"github.com/metacubex/mihomo/adapter/outboundgroup"
"github.com/metacubex/mihomo/tunnel"
)
// mihomo has no hook for a health check moving a URLTest or Fallback pick, so
// the picks are re-read on this timer while the host watches.
const routePollInterval = time.Second
type pickableGroup interface {
outboundgroup.ProxyGroup
outboundgroup.SelectAble
}
type routeTracker struct {
mu sync.Mutex
epoch uint64
picksVersion uint64
picks map[string]string
providerVersions map[string]uint32
watched bool
watchSeq uint64
stopPoll context.CancelFunc
}
var (
currentRoute routeTracker
publishRoute = func(state RouteState) {
sendMessage(Message{Type: RouteChangedMessage, Data: state})
}
)
func routeStateLocked() RouteState {
picks := currentRoute.picks
if picks == nil {
picks = map[string]string{}
}
return RouteState{
CoreEpoch: currentRoute.epoch,
PicksVersion: currentRoute.picksVersion,
Picks: maps.Clone(picks),
}
}
func routeState() RouteState {
currentRoute.mu.Lock()
defer currentRoute.mu.Unlock()
return routeStateLocked()
}
func routeStamp() (epoch, picksVersion uint64) {
currentRoute.mu.Lock()
defer currentRoute.mu.Unlock()
return currentRoute.epoch, currentRoute.picksVersion
}
func readPicks() (map[string]string, map[string]uint32) {
picks := map[string]string{}
for name, proxy := range tunnel.AllProxies() {
outbound, ok := proxy.(*adapter.Proxy)
if !ok {
continue
}
group, ok := outbound.ProxyAdapter.(pickableGroup)
if !ok {
continue
}
picks[name] = group.Now()
}
providers := tunnel.ProvidersSnapshot()
versions := make(map[string]uint32, len(providers))
for name, p := range providers {
versions[name] = p.Version()
}
return picks, versions
}
// A provider that swapped its list is a structural change: the names may
// survive while the servers behind them do not. The caller holds selectMu.
func refreshRouteLocked(structural bool) {
picks, versions := readPicks()
currentRoute.mu.Lock()
if currentRoute.providerVersions != nil && !maps.Equal(versions, currentRoute.providerVersions) {
structural = true
}
currentRoute.providerVersions = versions
if structural {
currentRoute.epoch++
}
changed := structural
if !maps.Equal(picks, currentRoute.picks) {
currentRoute.picks = picks
currentRoute.picksVersion++
changed = true
}
state := routeStateLocked()
watched := currentRoute.watched
currentRoute.mu.Unlock()
if changed && watched {
publishRoute(state)
}
}
func refreshRoute() {
selectMu.Lock()
defer selectMu.Unlock()
refreshRouteLocked(false)
}
func bumpRouteEpoch() {
selectMu.Lock()
defer selectMu.Unlock()
refreshRouteLocked(true)
}
func handleWatchRoute(watch bool, seq uint64) RouteState {
refreshRoute()
currentRoute.mu.Lock()
defer currentRoute.mu.Unlock()
if seq < currentRoute.watchSeq {
return routeStateLocked()
}
currentRoute.watchSeq = seq
currentRoute.watched = watch
if watch && currentRoute.stopPoll == nil {
ctx, cancel := context.WithCancel(context.Background())
currentRoute.stopPoll = cancel
go pollRoute(ctx)
}
if !watch {
stopRoutePollLocked()
}
return routeStateLocked()
}
func stopRouteWatch() {
currentRoute.mu.Lock()
defer currentRoute.mu.Unlock()
currentRoute.watchSeq = max(currentRoute.watchSeq, methodCallSeq.Load())
currentRoute.watched = false
stopRoutePollLocked()
}
func stopRoutePollLocked() {
if currentRoute.stopPoll != nil {
currentRoute.stopPoll()
currentRoute.stopPoll = nil
}
}
// With the listeners down the host pins every probe to DIRECT, so the picks
// do not matter until they come back.
func pollRoute(ctx context.Context) {
ticker := time.NewTicker(routePollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if isRunning.Load() {
refreshRoute()
}
}
}
}
+266
View File
@@ -0,0 +1,266 @@
package main
import (
"maps"
"net/http"
"sync"
"testing"
"github.com/metacubex/mihomo/adapter"
"github.com/metacubex/mihomo/adapter/outboundgroup"
"github.com/metacubex/mihomo/adapter/provider"
"github.com/metacubex/mihomo/config"
"github.com/metacubex/mihomo/constant"
cp "github.com/metacubex/mihomo/constant/provider"
"github.com/metacubex/mihomo/tunnel"
)
type routeRecorder struct {
mu sync.Mutex
states []RouteState
}
func (r *routeRecorder) record(state RouteState) {
r.mu.Lock()
defer r.mu.Unlock()
r.states = append(r.states, state)
}
func (r *routeRecorder) published() []RouteState {
r.mu.Lock()
defer r.mu.Unlock()
return append([]RouteState(nil), r.states...)
}
func clearRoute() {
stopRouteWatch()
currentRoute.mu.Lock()
defer currentRoute.mu.Unlock()
currentRoute.epoch = 0
currentRoute.picksVersion = 0
currentRoute.picks = nil
currentRoute.providerVersions = nil
currentRoute.watchSeq = 0
}
func recordRoutes(t *testing.T) *routeRecorder {
t.Helper()
clearRoute()
recorder := &routeRecorder{}
previous := publishRoute
publishRoute = recorder.record
t.Cleanup(func() {
clearRoute()
publishRoute = previous
})
return recorder
}
func stampRoute(epoch, picksVersion uint64) {
currentRoute.mu.Lock()
defer currentRoute.mu.Unlock()
currentRoute.epoch = epoch
currentRoute.picksVersion = picksVersion
}
func selectorOver(t *testing.T, name string, members ...constant.Proxy) constant.Proxy {
t.Helper()
health := provider.NewHealthCheck(members, "", 0, 0, true, nil)
pd, err := provider.NewCompatibleProvider(name+"-provider", members, health)
if err != nil {
t.Fatalf("NewCompatibleProvider: %v", err)
}
group, err := outboundgroup.NewSelector(
outboundgroup.GroupCommonOption{Name: name},
outboundgroup.SelectorOption{},
nil,
[]cp.ProxyProvider{pd},
)
if err != nil {
t.Fatalf("NewSelector: %v", err)
}
return adapter.NewProxy(group)
}
func TestHandleChangeProxyReportsWhetherThePickMoved(t *testing.T) {
recorder := recordRoutes(t)
group := selectorGroup(t, "group", "node-a", "node-b")
tunnel.UpdateProxies(map[string]constant.Proxy{"group": group}, nil)
t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) })
state := handleWatchRoute(true, 0)
if state.Picks["group"] != "node-a" {
t.Fatalf("picks = %v, want the group's first member before any selection", state.Picks)
}
result := handleChangeProxy(&ChangeProxyParams{GroupName: "group", ProxyName: "node-b"})
if result.Message != "" || !result.Changed {
t.Fatalf("result = %+v, want a silent change", result)
}
published := recorder.published()
if len(published) != 1 {
t.Fatalf("published %d route states, want 1", len(published))
}
if published[0].Picks["group"] != "node-b" || published[0].PicksVersion != state.PicksVersion+1 {
t.Errorf("published = %+v, want the new pick under the next version", published[0])
}
result = handleChangeProxy(&ChangeProxyParams{GroupName: "group", ProxyName: "node-b"})
if result.Changed {
t.Error("re-selecting the current node reported a change")
}
if len(recorder.published()) != 1 {
t.Error("re-selecting the current node published a route state")
}
}
func TestNestedGroupSwitchPublishesEveryPick(t *testing.T) {
recorder := recordRoutes(t)
nested := selectorGroup(t, "Nested", "HK-01", "HK-02")
outer := selectorOver(t, "Proxy", nested, namedProxy("JP-01"))
tunnel.UpdateProxies(map[string]constant.Proxy{"Proxy": outer, "Nested": nested}, nil)
t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) })
handleWatchRoute(true, 0)
handleChangeProxy(&ChangeProxyParams{GroupName: "Nested", ProxyName: "HK-02"})
published := recorder.published()
want := map[string]string{"Proxy": "Nested", "Nested": "HK-02"}
if len(published) != 1 || !maps.Equal(published[0].Picks, want) {
t.Fatalf("published = %+v, want one state with picks %v", published, want)
}
}
func TestRouteEpochFollowsStructuralChanges(t *testing.T) {
recorder := recordRoutes(t)
handleWatchRoute(true, 0)
before := routeState().CoreEpoch
bumpRouteEpoch()
if got := routeState().CoreEpoch; got != before+1 {
t.Fatalf("epoch = %d after an apply, want %d", got, before+1)
}
if len(recorder.published()) != 1 {
t.Error("an apply did not publish the new epoch")
}
general := &config.General{}
general.Mode = tunnel.Rule
withCurrentConfig(t, &config.Config{General: general, Controller: &config.Controller{}})
global := tunnel.Global
if err := updateConfig(&UpdateParams{Mode: &global}); err != nil {
t.Fatalf("updateConfig: %v", err)
}
if got := routeState().CoreEpoch; got != before+2 {
t.Errorf("epoch = %d after a mode switch, want %d", got, before+2)
}
if err := updateConfig(&UpdateParams{Mode: &global}); err != nil {
t.Fatalf("updateConfig: %v", err)
}
if got := routeState().CoreEpoch; got != before+2 {
t.Errorf("epoch = %d after an unchanged mode, want it left at %d", got, before+2)
}
}
func TestProviderRefreshAdvancesTheEpoch(t *testing.T) {
recordRoutes(t)
subscription := newCachingProvider("subscription", "node-a")
withTunnelProviders(t, map[string]cp.ProxyProvider{"subscription": subscription}, nil)
before := handleWatchRoute(true, 0).CoreEpoch
refreshRoute()
if got := routeState().CoreEpoch; got != before {
t.Fatalf("epoch = %d after a refresh without a provider change, want %d", got, before)
}
subscription.setProxies("node-b")
refreshRoute()
if got := routeState().CoreEpoch; got != before+1 {
t.Errorf("epoch = %d after the provider swapped its list, want %d", got, before+1)
}
}
func TestProbeAnswersCarryTheRouteStamp(t *testing.T) {
recordRoutes(t)
stampRoute(7, 42)
withProbeProxies(t, "node-a")
server := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ip=203.0.113.7"))
})
answer := handleOutboundIp(&OutboundIpParams{ProxyName: "node-a", Urls: []string{server.URL}, Timeout: 4000})
if answer.CoreEpoch != 7 || answer.PicksVersion != 42 {
t.Errorf("outbound ip stamp = %d/%d, want 7/42", answer.CoreEpoch, answer.PicksVersion)
}
failed := handleOutboundIp(&OutboundIpParams{ProxyName: "node-a", Timeout: 1000})
if failed.Error == "" || failed.CoreEpoch != 7 || failed.PicksVersion != 42 {
t.Errorf("failed outbound ip = %+v, want an error stamped 7/42", failed)
}
withServiceCheckers(t, stubChecker("alpha", ServiceCheckItem{Status: serviceAvailable}))
items := handleServiceCheck(&ServiceCheckParams{Timeout: 1000})
if items[0].CoreEpoch != 7 || items[0].PicksVersion != 42 {
t.Errorf("service check stamp = %d/%d, want 7/42", items[0].CoreEpoch, items[0].PicksVersion)
}
}
func TestWatchRoutePollsOnlyWhileWatched(t *testing.T) {
recordRoutes(t)
handleWatchRoute(true, 0)
currentRoute.mu.Lock()
polling := currentRoute.stopPoll != nil
currentRoute.mu.Unlock()
if !polling {
t.Fatal("watching the route did not start the poll")
}
handleWatchRoute(false, 0)
currentRoute.mu.Lock()
polling = currentRoute.stopPoll != nil
watched := currentRoute.watched
currentRoute.mu.Unlock()
if polling || watched {
t.Error("releasing the route left the poll running")
}
}
func TestWatchRouteIgnoresAnOlderIntent(t *testing.T) {
recordRoutes(t)
handleWatchRoute(false, 2)
handleWatchRoute(true, 1)
currentRoute.mu.Lock()
polling := currentRoute.stopPoll != nil
watched := currentRoute.watched
currentRoute.mu.Unlock()
if polling || watched {
t.Error("a watch overtaken by a later release restarted the poll")
}
}
func TestStopRouteWatchOutranksEarlierWatches(t *testing.T) {
recordRoutes(t)
previous := methodCallSeq.Load()
t.Cleanup(func() { methodCallSeq.Store(previous) })
methodCallSeq.Store(previous + 5)
stopRouteWatch()
handleWatchRoute(true, previous+4)
currentRoute.mu.Lock()
polling := currentRoute.stopPoll != nil
currentRoute.mu.Unlock()
if polling {
t.Fatal("a watch issued before the stop restarted the poll")
}
handleWatchRoute(true, previous+6)
currentRoute.mu.Lock()
polling = currentRoute.stopPoll != nil
currentRoute.mu.Unlock()
handleWatchRoute(false, previous+7)
if !polling {
t.Error("a watch issued after the stop was ignored")
}
}
+6 -1
View File
@@ -55,6 +55,11 @@ func deliverEvent(data []byte) {
send(data)
}
// The desktop core exits with its host connection, so it is always heard.
func hasEventListener() bool {
return true
}
func writeFrame(w io.Writer, data []byte) (int, error) {
if len(data) > maxIPCFrameSize {
return 0, fmt.Errorf("IPC frame exceeds %d bytes", maxIPCFrameSize)
@@ -212,6 +217,6 @@ func serve(dialed ipcConn) {
continue
}
go handleMethodCall(call, newMethodResponse(call.ID, nil))
dispatchMethodCall(call, newMethodResponse(call.ID, nil))
}
}
+304
View File
@@ -0,0 +1,304 @@
package main
import (
"context"
"strings"
"sync"
"time"
"github.com/metacubex/http"
)
const (
// A sweep runs a few checks at a time so it cannot take every probe slot.
// It therefore runs in waves, and a rule like Disney+ spends several
// requests in sequence, so the run gets a multiple of the request budget.
serviceCheckConcurrency = 4
serviceSweepBudgetFactor = 6
// Region markers sit deep inside these pages; endpoints that answer with a
// few lines get the small cap.
serviceHtmlMaxBody = 512 * 1024
serviceSmallMaxBody = 16 * 1024
// Gemini's region sits past 800 KiB, Prime Video's at times past 4 MiB.
serviceScanMaxBody = 8 * 1024 * 1024
)
// Statuses a check reports. Dart maps each to a localized label, so these
// strings are part of the contract and cannot be reworded on one side alone.
const (
serviceAvailable = "available"
serviceUnavailable = "unavailable"
serviceRestricted = "restricted"
serviceDisallowedIsp = "disallowed-isp"
serviceBlocked = "blocked"
serviceUnsupportedRegion = "unsupported-region"
serviceOriginalsOnly = "originals-only"
serviceComingSoon = "coming-soon"
serviceTimeout = "timeout"
serviceFailed = "failed"
)
// An empty Names runs every check.
type ServiceCheckParams struct {
ProxyName string `json:"proxy-name"`
Names []string `json:"names"`
Timeout int64 `json:"timeout"`
}
// runServiceCheck stamps Name; a blank Status means a rule has yet to judge it.
type ServiceCheckItem struct {
Name string `json:"name"`
Status string `json:"status"`
Region string `json:"region,omitempty"`
Delay int64 `json:"delay,omitempty"`
Chains []string `json:"chains,omitempty"`
CheckedAt int64 `json:"checked-at"`
CoreEpoch uint64 `json:"core-epoch"`
PicksVersion uint64 `json:"picks-version"`
}
var (
serviceClaimsMu sync.Mutex
serviceClaims = map[string]*context.CancelFunc{}
)
type serviceEnv struct {
ctx context.Context
proxyName string
timeout time.Duration
}
func (e serviceEnv) get(url string, maxBody int64) *ProbeResult {
return e.send(probeRequest{method: http.MethodGet, url: url, maxBody: maxBody})
}
func (e serviceEnv) scan(url string, until func(tail string) bool) *ProbeResult {
return e.send(probeRequest{method: http.MethodGet, url: url, maxBody: serviceScanMaxBody, until: until})
}
func (e serviceEnv) send(req probeRequest) *ProbeResult {
req.proxyName = e.proxyName
req.timeout = e.timeout
headers := map[string]string{"User-Agent": browserUserAgent}
for name, value := range req.headers {
headers[name] = value
}
req.headers = headers
return runProbe(e.ctx, req)
}
type serviceChecker struct {
name string
check func(serviceEnv) ServiceCheckItem
}
var serviceCheckers = []serviceChecker{
{name: "google", check: checkReachable("https://www.google.com/generate_204")},
{name: "github", check: checkReachable("https://github.com/")},
{name: "youtube", check: checkYouTubePremium},
{name: "chatgpt", check: checkChatGpt},
{name: "claude", check: checkClaude},
{name: "gemini", check: checkGemini},
{name: "netflix", check: checkNetflix},
{name: "disney-plus", check: checkDisneyPlus},
{name: "prime-video", check: checkPrimeVideo},
{name: "spotify", check: checkSpotify},
{name: "tiktok", check: checkTikTok},
{name: "bilibili", check: checkBilibili},
}
func handleServiceCheck(params *ServiceCheckParams) []ServiceCheckItem {
wanted := selectedCheckers(params.Names)
items := make([]ServiceCheckItem, len(wanted))
if len(wanted) == 0 {
return items
}
timeout := probeTimeout(params.Timeout)
ctx, cancel := context.WithTimeout(context.Background(), timeout*serviceSweepBudgetFactor)
defer cancel()
type slot struct {
index int
checker serviceChecker
ctx context.Context
}
queue := make(chan slot)
done := make(chan struct{})
workers := min(serviceCheckConcurrency, len(wanted))
for worker := 0; worker < workers; worker++ {
go func() {
defer func() { done <- struct{}{} }()
for job := range queue {
items[job.index] = runServiceCheck(job.ctx, job.checker, params.ProxyName, timeout)
}
}()
}
contexts, release := claimServices(ctx, wanted)
defer release()
for index, checker := range wanted {
queue <- slot{index: index, checker: checker, ctx: contexts[index]}
}
close(queue)
for worker := 0; worker < workers; worker++ {
<-done
}
return items
}
// A misbehaving rule must not take the sweep down with it.
func runServiceCheck(ctx context.Context, checker serviceChecker, proxyName string, timeout time.Duration) (item ServiceCheckItem) {
epoch, picksVersion := routeStamp()
item = ServiceCheckItem{Status: serviceFailed}
defer func() {
if recovered := recover(); recovered != nil {
item = ServiceCheckItem{Status: serviceFailed}
}
item.Name = checker.name
item.CheckedAt = time.Now().UnixMilli()
item.CoreEpoch = epoch
item.PicksVersion = picksVersion
}()
if ctx.Err() != nil {
return item
}
env := serviceEnv{ctx: ctx, proxyName: proxyName, timeout: timeout}
item = checker.check(env)
if item.Status == serviceTimeout && hasBudget(ctx, timeout) {
item = checker.check(env)
}
return item
}
// The host asks for a service again only once the older answer can no longer
// be used, so a newer check cancels the older one and frees its probe slots.
func claimServices(parent context.Context, checkers []serviceChecker) ([]context.Context, func()) {
contexts := make([]context.Context, len(checkers))
claimed := make(map[string]context.Context, len(checkers))
var releases []func()
for index, checker := range checkers {
ctx, ok := claimed[checker.name]
if !ok {
var release func()
ctx, release = claimService(parent, checker.name)
claimed[checker.name] = ctx
releases = append(releases, release)
}
contexts[index] = ctx
}
return contexts, func() {
for _, release := range releases {
release()
}
}
}
func claimService(parent context.Context, name string) (context.Context, func()) {
ctx, cancel := context.WithCancel(parent)
claim := &cancel
serviceClaimsMu.Lock()
if previous := serviceClaims[name]; previous != nil {
(*previous)()
}
serviceClaims[name] = claim
serviceClaimsMu.Unlock()
return ctx, func() {
cancel()
serviceClaimsMu.Lock()
if serviceClaims[name] == claim {
delete(serviceClaims, name)
}
serviceClaimsMu.Unlock()
}
}
func hasBudget(ctx context.Context, timeout time.Duration) bool {
deadline, ok := ctx.Deadline()
return !ok || time.Until(deadline) >= timeout
}
func selectedCheckers(names []string) []serviceChecker {
if len(names) == 0 {
return serviceCheckers
}
wanted := make([]serviceChecker, 0, len(names))
for _, name := range names {
for _, checker := range serviceCheckers {
if checker.name == name {
wanted = append(wanted, checker)
break
}
}
}
return wanted
}
// checkReachable is the rule for a target with no unlock semantics of its own.
func checkReachable(url string) func(serviceEnv) ServiceCheckItem {
return func(env serviceEnv) ServiceCheckItem {
result := env.get(url, 0)
item := itemFrom(result)
if item.Status != "" {
return item
}
item.Status = statusFromCode(result.StatusCode)
return item
}
}
func itemFrom(result *ProbeResult) ServiceCheckItem {
if result == nil {
return ServiceCheckItem{Status: serviceFailed}
}
item := ServiceCheckItem{Delay: result.Delay, Chains: result.Chains}
switch result.Error {
case "":
case probeErrorTimeout:
item.Status = serviceTimeout
item.Delay = 0
default:
item.Status = serviceFailed
item.Delay = 0
}
return item
}
func statusFromCode(code int) string {
switch {
case code >= 200 && code < 300:
return serviceAvailable
case code == http.StatusUnauthorized,
code == http.StatusForbidden,
code == http.StatusTooManyRequests,
code == http.StatusUnavailableForLegalReasons:
return serviceRestricted
default:
return serviceUnavailable
}
}
func answered(item ServiceCheckItem, result *ProbeResult) bool {
return item.Status == "" && result != nil
}
func bodyContains(result *ProbeResult, needles ...string) bool {
body := strings.ToLower(result.Body)
for _, needle := range needles {
if strings.Contains(body, needle) {
return true
}
}
return false
}
func traceValue(body string, key string) string {
for _, line := range strings.Split(body, "\n") {
if rest, found := strings.CutPrefix(line, key+"="); found {
return strings.TrimSpace(rest)
}
}
return ""
}
+270
View File
@@ -0,0 +1,270 @@
package main
import (
"net/http"
"sync"
"sync/atomic"
"testing"
"time"
)
func withServiceCheckers(t *testing.T, checkers ...serviceChecker) {
t.Helper()
previous := serviceCheckers
serviceCheckers = checkers
t.Cleanup(func() { serviceCheckers = previous })
}
func stubChecker(name string, item ServiceCheckItem) serviceChecker {
return serviceChecker{name: name, check: func(serviceEnv) ServiceCheckItem { return item }}
}
func TestHandleServiceCheckRunsEveryCheckerByDefault(t *testing.T) {
withServiceCheckers(t,
stubChecker("alpha", ServiceCheckItem{Status: serviceAvailable, Region: "US"}),
stubChecker("beta", ServiceCheckItem{Status: serviceUnsupportedRegion}),
)
items := handleServiceCheck(&ServiceCheckParams{Timeout: 1000})
if len(items) != 2 {
t.Fatalf("items = %d, want 2", len(items))
}
if items[0].Name != "alpha" || items[0].Status != serviceAvailable || items[0].Region != "US" {
t.Errorf("items[0] = %+v", items[0])
}
if items[1].Name != "beta" || items[1].Status != serviceUnsupportedRegion {
t.Errorf("items[1] = %+v", items[1])
}
for _, item := range items {
if item.CheckedAt == 0 {
t.Errorf("%s has no check time", item.Name)
}
}
}
func TestHandleServiceCheckStampsTheRegisteredName(t *testing.T) {
withServiceCheckers(t,
stubChecker("alpha", ServiceCheckItem{Name: "stale", Status: serviceAvailable}),
)
items := handleServiceCheck(&ServiceCheckParams{Timeout: 1000})
if items[0].Name != "alpha" {
t.Errorf("name = %q, want the registry's rather than the rule's", items[0].Name)
}
}
func TestHandleServiceCheckKeepsTheRequestedOrder(t *testing.T) {
withServiceCheckers(t,
stubChecker("alpha", ServiceCheckItem{Status: serviceAvailable}),
stubChecker("beta", ServiceCheckItem{Status: serviceAvailable}),
stubChecker("gamma", ServiceCheckItem{Status: serviceAvailable}),
)
items := handleServiceCheck(&ServiceCheckParams{Names: []string{"gamma", "alpha"}, Timeout: 1000})
if len(items) != 2 {
t.Fatalf("items = %d, want 2", len(items))
}
if items[0].Name != "gamma" || items[1].Name != "alpha" {
t.Errorf("names = %q, %q, want gamma, alpha", items[0].Name, items[1].Name)
}
}
func TestHandleServiceCheckIgnoresAnUnknownName(t *testing.T) {
withServiceCheckers(t, stubChecker("alpha", ServiceCheckItem{Status: serviceAvailable}))
items := handleServiceCheck(&ServiceCheckParams{Names: []string{"nope"}, Timeout: 1000})
if len(items) != 0 {
t.Fatalf("items = %d, want none", len(items))
}
}
func TestHandleServiceCheckSurvivesAPanickingRule(t *testing.T) {
withServiceCheckers(t,
serviceChecker{name: "boom", check: func(serviceEnv) ServiceCheckItem { panic("rule blew up") }},
stubChecker("alpha", ServiceCheckItem{Status: serviceAvailable}),
)
items := handleServiceCheck(&ServiceCheckParams{Timeout: 1000})
if len(items) != 2 {
t.Fatalf("items = %d, want 2", len(items))
}
if items[0].Status != serviceFailed {
t.Errorf("panicking rule reported %q, want %q", items[0].Status, serviceFailed)
}
if items[1].Status != serviceAvailable {
t.Errorf("the sibling rule reported %q, want it unaffected", items[1].Status)
}
}
func TestHandleServiceCheckBoundsConcurrency(t *testing.T) {
var mu sync.Mutex
var running, peak int
var total atomic.Int32
blocking := func(name string) serviceChecker {
return serviceChecker{name: name, check: func(serviceEnv) ServiceCheckItem {
mu.Lock()
running++
if running > peak {
peak = running
}
mu.Unlock()
time.Sleep(20 * time.Millisecond)
mu.Lock()
running--
mu.Unlock()
total.Add(1)
return ServiceCheckItem{Status: serviceAvailable}
}}
}
checkers := make([]serviceChecker, 0, 12)
for index := 0; index < 12; index++ {
checkers = append(checkers, blocking("x"))
}
withServiceCheckers(t, checkers...)
handleServiceCheck(&ServiceCheckParams{Timeout: 5000})
if got := total.Load(); got != 12 {
t.Errorf("ran %d checks, want 12", got)
}
mu.Lock()
defer mu.Unlock()
if peak > serviceCheckConcurrency {
t.Errorf("peak concurrency = %d, want at most %d", peak, serviceCheckConcurrency)
}
}
func TestHandleServiceCheckRetriesATimeoutOnce(t *testing.T) {
var calls atomic.Int32
withServiceCheckers(t, serviceChecker{name: "flaky", check: func(serviceEnv) ServiceCheckItem {
if calls.Add(1) == 1 {
return ServiceCheckItem{Status: serviceTimeout}
}
return ServiceCheckItem{Status: serviceAvailable}
}})
items := handleServiceCheck(&ServiceCheckParams{Timeout: 1000})
if items[0].Status != serviceAvailable {
t.Errorf("status = %q, want the retry's answer", items[0].Status)
}
if got := calls.Load(); got != 2 {
t.Errorf("ran %d times, want 2", got)
}
}
func TestHandleServiceCheckReportsARepeatedTimeout(t *testing.T) {
var calls atomic.Int32
withServiceCheckers(t, serviceChecker{name: "down", check: func(serviceEnv) ServiceCheckItem {
calls.Add(1)
return ServiceCheckItem{Status: serviceTimeout}
}})
items := handleServiceCheck(&ServiceCheckParams{Timeout: 1000})
if items[0].Status != serviceTimeout {
t.Errorf("status = %q, want %q", items[0].Status, serviceTimeout)
}
if got := calls.Load(); got != 2 {
t.Errorf("ran %d times, want a single retry", got)
}
}
func TestHandleServiceCheckCancelsTheOlderCheckOfTheSameService(t *testing.T) {
started := make(chan struct{}, 2)
var calls atomic.Int32
withServiceCheckers(t,
serviceChecker{name: "alpha", check: func(env serviceEnv) ServiceCheckItem {
if calls.Add(1) > 1 {
return ServiceCheckItem{Status: serviceAvailable}
}
started <- struct{}{}
<-env.ctx.Done()
return ServiceCheckItem{Status: serviceFailed}
}},
serviceChecker{name: "beta", check: func(env serviceEnv) ServiceCheckItem {
started <- struct{}{}
select {
case <-env.ctx.Done():
return ServiceCheckItem{Status: serviceFailed}
case <-time.After(200 * time.Millisecond):
return ServiceCheckItem{Status: serviceAvailable}
}
}},
)
older := make(chan []ServiceCheckItem)
go func() { older <- handleServiceCheck(&ServiceCheckParams{Timeout: 5000}) }()
<-started
<-started
newer := handleServiceCheck(&ServiceCheckParams{Names: []string{"alpha"}, Timeout: 5000})
if newer[0].Status != serviceAvailable {
t.Errorf("newer alpha = %q, want %q", newer[0].Status, serviceAvailable)
}
select {
case items := <-older:
if items[0].Status != serviceFailed {
t.Errorf("older alpha = %q, want it cancelled", items[0].Status)
}
if items[1].Status != serviceAvailable {
t.Errorf("older beta = %q, want it left running", items[1].Status)
}
case <-time.After(2 * time.Second):
t.Fatal("the older sweep kept running after its service was asked again")
}
}
func TestStatusFromCode(t *testing.T) {
cases := map[int]string{
http.StatusOK: serviceAvailable,
http.StatusNoContent: serviceAvailable,
http.StatusUnauthorized: serviceRestricted,
http.StatusForbidden: serviceRestricted,
http.StatusTooManyRequests: serviceRestricted,
http.StatusUnavailableForLegalReasons: serviceRestricted,
http.StatusNotFound: serviceUnavailable,
http.StatusInternalServerError: serviceUnavailable,
}
for code, want := range cases {
if got := statusFromCode(code); got != want {
t.Errorf("statusFromCode(%d) = %q, want %q", code, got, want)
}
}
}
func TestItemFromMapsTransportErrors(t *testing.T) {
timedOut := itemFrom(&ProbeResult{Error: probeErrorTimeout, Delay: 900})
if timedOut.Status != serviceTimeout {
t.Errorf("status = %q, want %q", timedOut.Status, serviceTimeout)
}
if timedOut.Delay != 0 {
t.Errorf("delay = %d, want it dropped for a timeout", timedOut.Delay)
}
if missing := itemFrom(nil); missing.Status != serviceFailed {
t.Errorf("status = %q, want %q when the probe got no slot", missing.Status, serviceFailed)
}
answered := itemFrom(&ProbeResult{StatusCode: http.StatusOK, Delay: 120})
if answered.Status != "" {
t.Errorf("status = %q, want it left for the rule to judge", answered.Status)
}
if answered.Delay != 120 {
t.Errorf("delay = %d, want 120", answered.Delay)
}
}
func TestTraceValue(t *testing.T) {
body := "fl=abc\nip=203.0.113.7\nloc=JP\nwarp=off\n"
if got := traceValue(body, "loc"); got != "JP" {
t.Errorf("loc = %q, want JP", got)
}
if got := traceValue(body, "colo"); got != "" {
t.Errorf("colo = %q, want empty", got)
}
}
+461
View File
@@ -0,0 +1,461 @@
package main
import (
"encoding/json"
"net/url"
"regexp"
"slices"
"strings"
"github.com/metacubex/http"
)
// disneyAuthHeader is the public browser client token Disney+ hands to any web
// visitor; the device and token calls below are rejected without it.
const disneyAuthHeader = "Bearer ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84"
var (
youTubeRegionPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)id=["']country-code["'][^>]*>\s*([A-Za-z]{2,3})\s*<`),
regexp.MustCompile(`"GL"\s*:\s*"([A-Za-z]{2})"`),
regexp.MustCompile(`"countryCode"\s*:\s*"([A-Za-z]{2})"`),
regexp.MustCompile(`"country_code"\s*:\s*"([A-Za-z]{2})"`),
}
tikTokRegionPattern = regexp.MustCompile(`"region"\s*:\s*"([a-zA-Z-]+)"`)
primeRegionPattern = regexp.MustCompile(`"currentTerritory":"([^"]+)"`)
disneyAssertionPattern = regexp.MustCompile(`"assertion"\s*:\s*"([^"]+)"`)
disneyRefreshPattern = regexp.MustCompile(`"refresh_token"\s*:\s*"([^"]+)"`)
disneyCountryPattern = regexp.MustCompile(`"countryCode"\s*:\s*"([^"]+)"`)
disneySupportedPattern = regexp.MustCompile(`"inSupportedLocation"\s*:\s*(false|true)`)
spotifyCountryPattern = regexp.MustCompile(`"countryCode"\s*:\s*"([^"]+)"`)
geminiRegionMarker = `,2,1,200,"`
claudeBlockedRegions = []string{"AF", "BY", "CN", "CU", "HK", "IR", "KP", "MO", "RU", "SY"}
geminiBlockedRegions = []string{"CHN", "RUS", "BLR", "CUB", "IRN", "PRK", "SYR", "HKG", "MAC"}
)
func firstSubmatch(pattern *regexp.Regexp, body string) string {
match := pattern.FindStringSubmatch(body)
if len(match) < 2 {
return ""
}
return match[1]
}
func checkClaude(env serviceEnv) ServiceCheckItem {
result := env.get("https://claude.ai/cdn-cgi/trace", serviceSmallMaxBody)
item := itemFrom(result)
if !answered(item, result) {
return item
}
region := normalizeRegion(traceValue(result.Body, "loc"))
if region == "" {
item.Status = serviceFailed
return item
}
item.Region = region
item.Status = serviceAvailable
if slices.Contains(claudeBlockedRegions, region) {
item.Status = serviceUnsupportedRegion
}
return item
}
func checkGemini(env serviceEnv) ServiceCheckItem {
result := env.scan("https://gemini.google.com", func(tail string) bool {
index := strings.Index(tail, geminiRegionMarker)
return index >= 0 && index+len(geminiRegionMarker)+3 <= len(tail)
})
item := itemFrom(result)
if !answered(item, result) {
return item
}
index := strings.Index(result.Body, geminiRegionMarker)
if index < 0 || index+len(geminiRegionMarker)+3 > len(result.Body) {
item.Status = serviceFailed
return item
}
start := index + len(geminiRegionMarker)
code := result.Body[start : start+3]
if !isAsciiUpper(code) {
item.Status = serviceFailed
return item
}
item.Region = normalizeRegion(code)
item.Status = serviceAvailable
if slices.Contains(geminiBlockedRegions, code) {
item.Status = serviceUnsupportedRegion
}
return item
}
// checkChatGpt folds Verge's separate iOS and Web results into one entry: the
// card shows a single row per service, and a split would put two identical
// OpenAI icons next to each other.
func checkChatGpt(env serviceEnv) ServiceCheckItem {
ios := env.get("https://ios.chat.openai.com/", serviceSmallMaxBody)
item := itemFrom(ios)
if answered(item, ios) {
item.Status = chatGptIosStatus(ios)
}
trace := env.get("https://chat.openai.com/cdn-cgi/trace", serviceSmallMaxBody)
if traceItem := itemFrom(trace); answered(traceItem, trace) {
item.Region = normalizeRegion(traceValue(trace.Body, "loc"))
}
web := env.get("https://api.openai.com/compliance/cookie_requirements", serviceSmallMaxBody)
if webItem := itemFrom(web); answered(webItem, web) &&
bodyContains(web, "unsupported_country") {
item.Status = serviceUnsupportedRegion
}
return item
}
func chatGptIosStatus(result *ProbeResult) string {
switch {
case bodyContains(result, "you may be connected to a disallowed isp"):
return serviceDisallowedIsp
case bodyContains(result, "sorry, you have been blocked"):
return serviceBlocked
case bodyContains(result, "request is not allowed. please try again later."):
// Being turned away as an API client is what a reachable region looks
// like here; anything else means the request never got that far.
return serviceAvailable
default:
return serviceFailed
}
}
func checkYouTubePremium(env serviceEnv) ServiceCheckItem {
result := env.get("https://www.youtube.com/premium?hl=en", serviceHtmlMaxBody)
item := itemFrom(result)
if !answered(item, result) {
return item
}
for _, pattern := range youTubeRegionPatterns {
if code := firstSubmatch(pattern, result.Body); code != "" {
item.Region = normalizeRegion(code)
break
}
}
switch {
case bodyContains(result,
"youtube premium is not available in your country",
"premium is not available in your country",
"premium is not available in your region"):
item.Status = serviceUnsupportedRegion
case result.StatusCode >= 200 && result.StatusCode < 300 &&
bodyContains(result, "youtube premium", "ad-free", `"browseid":"spunlimited"`):
item.Status = serviceAvailable
default:
item.Status = statusFromCode(result.StatusCode)
}
return item
}
func checkSpotify(env serviceEnv) ServiceCheckItem {
result := env.get(
"https://www.spotify.com/api/content/v1/country-selector?platform=web&format=json",
serviceSmallMaxBody,
)
item := itemFrom(result)
if !answered(item, result) {
return item
}
item.Region = normalizeRegion(spotifyRegion(result))
switch {
case result.StatusCode == http.StatusForbidden ||
result.StatusCode == http.StatusUnavailableForLegalReasons:
item.Status = serviceUnsupportedRegion
case result.StatusCode < 200 || result.StatusCode >= 300:
item.Status = statusFromCode(result.StatusCode)
case bodyContains(result, "not available in your country"):
item.Status = serviceUnsupportedRegion
default:
item.Status = serviceAvailable
}
return item
}
// spotifyRegion prefers the country Spotify redirected the request into, and
// falls back to the one named in the payload.
func spotifyRegion(result *ProbeResult) string {
if final, err := url.Parse(result.Url); err == nil {
segments := strings.Split(strings.Trim(final.Path, "/"), "/")
if first := segments[0]; first != "" && first != "api" {
return strings.Split(first, "-")[0]
}
}
return firstSubmatch(spotifyCountryPattern, result.Body)
}
func checkTikTok(env serviceEnv) ServiceCheckItem {
result := env.get("https://www.tiktok.com/cdn-cgi/trace", serviceSmallMaxBody)
item := itemFrom(result)
if answered(item, result) {
item.Status = tikTokStatus(result)
item.Region = normalizeRegion(tikTokRegion(result.Body))
}
if item.Region != "" && item.Status != serviceFailed {
return item
}
fallback := env.get("https://www.tiktok.com/", serviceHtmlMaxBody)
fallbackItem := itemFrom(fallback)
if !answered(fallbackItem, fallback) {
return item
}
if item.Status != serviceUnsupportedRegion {
item.Status = tikTokStatus(fallback)
}
if item.Region == "" {
item.Region = normalizeRegion(tikTokRegion(fallback.Body))
}
if item.Delay == 0 {
item.Delay = fallbackItem.Delay
}
if len(item.Chains) == 0 {
item.Chains = fallbackItem.Chains
}
return item
}
func tikTokStatus(result *ProbeResult) string {
switch {
case result.StatusCode == http.StatusForbidden ||
result.StatusCode == http.StatusUnavailableForLegalReasons:
return serviceUnsupportedRegion
case result.StatusCode < 200 || result.StatusCode >= 300:
return serviceFailed
case bodyContains(result, "access denied", "not available in your region", "tiktok is not available"):
return serviceUnsupportedRegion
default:
return serviceAvailable
}
}
func tikTokRegion(body string) string {
raw := firstSubmatch(tikTokRegionPattern, body)
if raw == "" {
return ""
}
return strings.Split(raw, "-")[0]
}
func checkBilibili(env serviceEnv) ServiceCheckItem {
const url = "https://api.bilibili.com/pgc/player/web/playurl?avid=18281381&cid=29892777&qn=0&type=&otype=json&ep_id=183799&fourk=1&fnver=0&fnval=16&module=bangumi"
result := env.get(url, serviceSmallMaxBody)
item := itemFrom(result)
if !answered(item, result) {
return item
}
var payload struct {
Code int `json:"code"`
}
if err := json.Unmarshal([]byte(result.Body), &payload); err != nil {
item.Status = serviceFailed
return item
}
switch payload.Code {
case 0:
item.Status = serviceAvailable
case -10403:
item.Status = serviceUnsupportedRegion
default:
item.Status = serviceFailed
}
return item
}
func checkPrimeVideo(env serviceEnv) ServiceCheckItem {
result := env.scan("https://www.primevideo.com", func(tail string) bool {
return strings.Contains(tail, "isServiceRestricted") || primeRegionPattern.MatchString(tail)
})
item := itemFrom(result)
if !answered(item, result) {
return item
}
if strings.Contains(result.Body, "isServiceRestricted") {
item.Status = serviceUnsupportedRegion
return item
}
region := firstSubmatch(primeRegionPattern, result.Body)
if region == "" {
item.Status = serviceFailed
return item
}
item.Region = normalizeRegion(region)
item.Status = serviceAvailable
return item
}
func checkNetflix(env serviceEnv) ServiceCheckItem {
if item := netflixFromCdn(env); item.Status == serviceAvailable || item.Status == serviceBlocked {
return item
}
first := env.get("https://www.netflix.com/title/81280792", 0)
firstItem := itemFrom(first)
if !answered(firstItem, first) {
return firstItem
}
second := env.get("https://www.netflix.com/title/70143836", 0)
secondItem := itemFrom(second)
if !answered(secondItem, second) {
return secondItem
}
item := firstItem
switch {
case first.StatusCode == http.StatusNotFound && second.StatusCode == http.StatusNotFound:
item.Status = serviceOriginalsOnly
case first.StatusCode == http.StatusForbidden || second.StatusCode == http.StatusForbidden:
item.Status = serviceUnsupportedRegion
case netflixServed(first.StatusCode) || netflixServed(second.StatusCode):
item.Status = serviceAvailable
item.Region = netflixRegion(env)
default:
item.Status = statusFromCode(first.StatusCode)
}
return item
}
func netflixServed(code int) bool {
return code == http.StatusOK || code == http.StatusMovedPermanently
}
// netflixRegion reads the country out of the redirect Netflix would send a
// visitor to, so the redirect must not be followed.
func netflixRegion(env serviceEnv) string {
result := env.send(probeRequest{
method: http.MethodGet,
url: "https://www.netflix.com/title/80018499",
noRedirect: true,
})
if result == nil || result.Error != "" || result.header == nil {
return ""
}
segments := strings.Split(result.header.Get("Location"), "/")
if len(segments) < 4 {
return ""
}
return normalizeRegion(strings.Split(segments[3], "-")[0])
}
func netflixFromCdn(env serviceEnv) ServiceCheckItem {
const url = "https://api.fast.com/netflix/speedtest/v2?https=true&token=YXNkZmFzZGxmbnNkYWZoYXNkZmhrYWxm&urlCount=5"
result := env.get(url, serviceSmallMaxBody)
item := itemFrom(result)
if !answered(item, result) {
return item
}
if result.StatusCode == http.StatusForbidden {
item.Status = serviceBlocked
return item
}
var payload struct {
Targets []struct {
Location struct {
Country string `json:"country"`
} `json:"location"`
} `json:"targets"`
}
if err := json.Unmarshal([]byte(result.Body), &payload); err != nil || len(payload.Targets) == 0 {
item.Status = serviceFailed
return item
}
item.Region = normalizeRegion(payload.Targets[0].Location.Country)
item.Status = serviceAvailable
return item
}
// checkDisneyPlus walks the browser hand-shake: register a device, exchange the
// assertion for a refresh token, then ask the session which region it landed
// in. Any step can answer on its own that the address is not welcome.
func checkDisneyPlus(env serviceEnv) ServiceCheckItem {
device := env.send(probeRequest{
method: http.MethodPost,
url: "https://disney.api.edge.bamgrid.com/devices",
headers: map[string]string{
"authorization": disneyAuthHeader,
"content-type": "application/json; charset=UTF-8",
},
body: []byte(`{"deviceFamily":"browser","applicationRuntime":"chrome","deviceProfile":"windows","attributes":{}}`),
maxBody: serviceSmallMaxBody,
})
item := itemFrom(device)
if !answered(item, device) {
return item
}
if device.StatusCode == http.StatusForbidden {
item.Status = serviceBlocked
return item
}
assertion := firstSubmatch(disneyAssertionPattern, device.Body)
if assertion == "" {
item.Status = serviceFailed
return item
}
form := strings.NewReplacer("{assertion}", url.QueryEscape(assertion)).Replace(
"grant_type=urn:ietf:params:oauth:grant-type:token-exchange" +
"&latitude=0&longitude=0&platform=browser" +
"&subject_token={assertion}" +
"&subject_token_type=urn:bamtech:params:oauth:token-type:device",
)
token := env.send(probeRequest{
method: http.MethodPost,
url: "https://disney.api.edge.bamgrid.com/token",
headers: map[string]string{
"authorization": disneyAuthHeader,
"content-type": "application/x-www-form-urlencoded",
},
body: []byte(form),
maxBody: serviceSmallMaxBody,
})
if tokenItem := itemFrom(token); !answered(tokenItem, token) {
return tokenItem
}
if strings.Contains(token.Body, "forbidden-location") || strings.Contains(token.Body, "403 ERROR") {
item.Status = serviceBlocked
return item
}
refresh := firstSubmatch(disneyRefreshPattern, token.Body)
if refresh == "" {
item.Status = serviceFailed
return item
}
query := `{"query":"mutation refreshToken($input: RefreshTokenInput!) { refreshToken(refreshToken: $input) { activeSession { sessionId } } }","variables":{"input":{"refreshToken":"` +
refresh + `"}}}`
session := env.send(probeRequest{
method: http.MethodPost,
url: "https://disney.api.edge.bamgrid.com/graph/v1/device/graphql",
headers: map[string]string{
"authorization": disneyAuthHeader,
"content-type": "application/json",
},
body: []byte(query),
maxBody: serviceSmallMaxBody,
})
sessionItem := itemFrom(session)
if !answered(sessionItem, session) || session.Body == "" || session.StatusCode >= 400 {
item.Status = serviceFailed
return item
}
region := normalizeRegion(firstSubmatch(disneyCountryPattern, session.Body))
if region == "" {
item.Status = serviceUnavailable
return item
}
item.Region = region
switch firstSubmatch(disneySupportedPattern, session.Body) {
case "true":
item.Status = serviceAvailable
case "false":
item.Status = serviceComingSoon
default:
item.Status = serviceFailed
}
return item
}

Some files were not shown because too many files have changed in this diff Show More