Compare commits

..

17 Commits

Author SHA1 Message Date
chen08209 a8655b0809 chore: bump version to 0.9.1+2026101701 2026-10-17 09:55:19 +08:00
chen08209 6c52d7a39a fix(ui): stop non-secret fields from opening a password keyboard
Changelog: Fix pasting and Chinese input in text fields
2026-10-17 09:55:10 +08:00
chen08209 4d68938d22 docs(agents): document surfaces, typography and the lib/ui layer 2026-10-17 09:54:29 +08:00
chen08209 213a13f62f feat(l10n): add French, Spanish and Korean
Changelog: French, Spanish and Korean translations
2026-10-17 09:54:29 +08:00
chen08209 fefbcf4598 feat(android): drop Firebase analytics and crash reporting
Changelog: No more Firebase analytics or crash reporting
2026-10-17 09:54:29 +08:00
chen08209 9f48781341 feat(network)!: set desktop TUN key by key over each profile's own
BREAKING CHANGE: Desktop TUN keys follow the profile unless picked; changed values carry over
Changelog: Per-key desktop TUN settings; system proxy and tray fixes
2026-10-17 09:54:29 +08:00
chen08209 dd952dd600 feat(theme): redesign theme colors with an iOS-style picker
Changelog: Redesigned theme colors and color styles
2026-10-17 09:54:29 +08:00
chen08209 bdbf4ab024 feat(dashboard): redesign the dashboard around the current exit
Changelog: A redesigned dashboard around the current exit
2026-10-17 09:54:29 +08:00
chen08209 347a74b940 feat(profiles): manage external resources from the profile details
Changelog: Profile-detail resources and include-all in custom groups
2026-10-17 09:54:29 +08:00
chen08209 eb47ee261f feat(proxies): show groups as cards and group tabs as chips
Changelog: Proxy groups as cards, group tabs as chips
2026-10-17 09:54:29 +08:00
chen08209 e82ebf785b feat(views): show connections, requests and DNS as tables
Changelog: Sortable tables for connections, requests and DNS
2026-10-17 09:54:29 +08:00
chen08209 ba15c313b4 feat(ui): add Liquid Glass and slide-to-pick popup menus
Changelog: Optional two-tier Liquid Glass and slide-to-pick menus
2026-10-17 09:54:29 +08:00
chen08209 568c49bb3a feat(ui): layer surfaces with fills and follow the iOS type scale
Changelog: iOS-style surfaces, alerts and sheets; clearer TV focus
2026-10-16 22:54:53 +08:00
chen08209 2201c199b8 perf(android): keep Core calls and events off the main thread
Changelog: Smoother Android UI while the proxy is busy
2026-10-16 22:54:53 +08:00
chen08209 5984c161c5 fix(core): keep very large profiles from crashing the app
Changelog: Large profiles no longer crash; downloads trust core CAs
2026-10-16 22:54:53 +08:00
chen08209 a5b6d300f6 test: run the suite from a few shared entrypoints 2026-10-16 22:54:53 +08:00
chen08209 03f9553824 chore(release): v0.9.0 2026-10-14 09:16:30 +08:00
648 changed files with 60980 additions and 38433 deletions
+456 -90
View File
@@ -10,8 +10,15 @@ Android lib mode:
classes gobind generates in `com.follow.clash.core.mobile`. The `:core` Gradle module builds it and takes in its
classes and libraries (see Android Core Build).
- The Android `:core` module owns the binding: `Core.kt` is the only caller of the generated `Mobile` class. Flutter
crosses the `${packageName}/service` MethodChannel through `lib/plugins/service.dart` and Android's `ServicePlugin`
rather than calling the Core directly.
crosses MethodChannels through `lib/plugins/service.dart` and Android's `ServicePlugin` rather than calling the Core
directly: `${packageName}/service` for lifecycle commands and Core events, `${packageName}/core` for Core method
calls.
- Flutter runs the UI isolate on Android's main thread (3.47 merges the two and ignores the opt-out in release), so
main-thread work anywhere in the process, the Core's included, costs frames. Only sending to Dart needs that thread;
a `MethodChannel.Result` may be answered from any thread. `${packageName}/core` is handled on a serial background
task queue and answered from the Core's goroutine, an event batch is encoded on the Core's thread and only sent on
the main one, `ForceGC` (called from `onLowMemory`/`onTrimMemory`) collects on its own goroutine, and
`NetworkObserveModule` refreshes DNS on its own looper.
- `lib/core/lib.dart` (`CoreLib`) implements the shared Core interface, gates method calls on its connection completer,
initializes and synchronizes Android shared state, and closes the native service path exactly once.
- Because Core is in the application process on Android, application RSS already includes Core memory.
@@ -87,16 +94,28 @@ into `sniff`. A `rule-set:` key of a custom profile's DNS policy counts as a use
section reaches the core, because setting the system clock is a host change; `loadConfig` in the Go wrapper also drops
it on Android, where the seccomp policy kills the process for `settimeofday`.
A custom profile's TUN page is the exception to covering the raw struct. The network settings own `tun` through
`PatchClashConfig.tun`, spread last over the profile's section and patched again on every `updateConfig`, so
`TunOverrideKey` offers only a few keys outside it that work in FlClash's TUN: `disable-icmp-forwarding`, and
`exclude-interface`, which only Linux routing reads. `test/models/tun_override_test.dart` fails if a key comes to
overlap `Tun`. Android starts its VPN from `core/tun` without the section, so TUN is listed on desktop only.
Being a part of the section, the page has no quick edit; see Quick Edit.
TUN is the exception to covering the raw struct. The network settings own only `Tun.coreJson` (`enable`, `device`,
`stack`, and `auto-route` always on), spread last over the profile's section and patched again on every
`updateConfig`, so `TunOverrideKey` offers only keys outside it that work in FlClash's TUN. Its keys exist app-wide
(`PatchClashConfig.tunOverride` with `tunOverrideKeys`, listed in the network page's TUN section as `OverrideEntry`
rows and added from its last row, as a custom group's options are) for every profile, and per custom profile, whose own keys `ProfileOverrides.overAppTun` lays over the app-wide ones. MTU,
congestion control, strict route and the route lists are such keys on desktop, so a profile's own values apply unless
picked; Android's TUN rows keep them in `Tun`, which its VPN service starts from, and a config saved before the move
carries values changed from their defaults into picked keys. `bypassPrivateRoute` stays a network setting: the task
appends the private ranges to whatever `route-exclude-address` results. `dns-hijack` is a key too: the task writes
`any:53` beneath the overrides, so a profile's own value never applies, a picked key replaces it, and the key starts at
that value. A key sing-tun reads on some platforms only is listed only there
(`_isRead` in `lib/views/config/tun.dart`): strict route on Linux and Windows, GSO and the interface, user ID and port
filters on Linux. `udp-timeout` reaches only the gvisor and system stacks and congestion control only mips, which their
descriptions say. Route address sets are left out: sing-tun applies them only through `auto-redirect`.
`test/models/tun_override_test.dart` fails if a key comes to overlap `Tun.coreJson`. Android starts its VPN from
`core/tun` without the section, so TUN is listed on desktop only. Being a part of the section, the page has no quick
edit; see Quick Edit.
A custom profile's overview keeps tiles for rules, providers and DNS, which every custom profile runs on, and puts the
dialer proxies, NTP, sniffer and TUN behind a single "more" tile, which opens a page listing them with their states.
Another rarely changed section joins that list rather than taking a tile of its own.
A custom profile's overview keeps rows for rules, providers and DNS, which every custom profile runs on, and puts the
dialer proxies, NTP, sniffer and TUN behind a single "more" row, which opens a page listing them with their states.
Both lists are the same grouped rows, glyph, title and state, ending in a chevron. Another rarely changed section joins
the "more" list rather than taking a row of its own.
Every page is an `OverrideView` over an `OverrideSpec` from `lib/views/config/override.dart`. The spec supplies the
section's keys, labels and fields; its `OverrideTarget` is where the model and keys live, `PatchOverrideTarget` for the
@@ -151,7 +170,8 @@ or a remote party reaching the privileged Core's control or filesystem APIs is a
A process running as the same account is not stopped. It can ask the Helper to start the Core
against a socket or pipe it owns, or run a setuid Core against a port it listens on, and so become the host that drives
a privileged Core: `initClash` chooses the home directory every path check anchors on, and `getConfig` reads any path.
a privileged Core: `initClash` chooses the home directory every path check anchors on, and `getConfig`,
`getProfileBase`, `getProfileOutbounds` and a `setupConfig` or `buildProfile` base read any path.
The Core connects to whatever address it is given and cannot tell that host from FlClash. An account check cannot
distinguish it from a same-account process that can already read FlClash's memory and files. This remains a user-to-root
or user-to-SYSTEM privilege escalation, not a complete security fix. An open external
@@ -319,6 +339,20 @@ directions. The envelope is the only JSON serialization layer: keep arguments, r
values rather than embedding pre-encoded JSON strings. Plain domain strings, such as country codes or provider contents,
remain strings.
A response over 1 MiB goes to a file under `<home>/responses/`, and the envelope carries only its `file`
(`core/spill.go`). Sent inline, Android copies it into the Java heap three times (the gomobile `byte[]`, the codec and a
direct buffer), and desktop IPC drops a frame above `maxIPCFrameSize`. `lib/core/spill.dart` reads a spilled file only
from that directory, decodes it in an isolate and deletes it; `initClash` clears what an earlier run left.
A profile file's proxies, groups and rules never cross the bridge. `getProfileBase` returns the config without
`proxies`, `proxy-groups`, `sub-rules` and `rules`, with their counts, the profile's MATCH rules and a sha256 digest.
`SetupAction.getProfile` patches it as before and sends the result as `SetupParams.base.overlay`. `core/profile_base.go`
puts the profile's bulk sections into the overlay, appends the profile's rules after the overlay's, writes `config.yaml`
for the Android service and the controller's reload, and parses it. `config.yaml` is encoded a slice at a time, because on a
profile of 100k proxies and 2M rules one `Encode` call doubles the peak heap. The preview reads the YAML `buildProfile`
merges into the spill directory, and the extend page only the names
and types `getProfileOutbounds` lists; a script extension still needs the whole config from `getConfig`.
Go event delivery is intentionally non-blocking:
- Loaded-provider, geo-update, and route-changed events use a state queue that never evicts; the rules for it are under
@@ -346,9 +380,15 @@ Dialer-loop events come from the fork's `component/proxydialer/patch.go`. mihomo
back to its own proxy until the stack overflows, so a dial that asks the same `dialer-proxy` for an address it is
already dialing through it fails with `ErrDialerLoop`. `core/hooks.go` reports each looping name at most once every
three seconds as a priority event, which the app shows as an error notice whether or not logs are captured. The custom
profile pages flag only the loops FlClash can follow on its own, through a group's own list or an include-all that no
filter narrows; what filters and providers pick, including provider proxies that carry their own `dialer-proxy`, is
left to this guard.
profile pages flag only the loops FlClash can follow on its own, through a group's own list or an `include-all-proxies`
that no filter, exclude filter or exclude type narrows; what filters and providers pick, including provider proxies
that carry their own `dialer-proxy`, is left to this guard.
`ProxyGroup.fromJson` reads `include-all` as both `include-all-proxies` and `include-all-providers`, as the core does, so
import and quick edit keep it. They take in only what the config declares, which is what some group of the profile
names: its local proxies and the file or url profiles in a `use`. Setting one declares nothing more, so the app's other
proxies and subscriptions stay out, and the core picks from the declared ones by the group's filters. Groups saved by
v0.9.0 kept `include-all-proxies` in their stored `definition`, and it takes effect again.
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.
@@ -376,8 +416,8 @@ holds the group; `delayTestPageGroup` marks it, ignores a second request for the
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.
through `ProbeStartHold` in `lib/views/dashboard/probe_start_hold.dart`: `ServiceStatusCard` holds the check on each
tile, and `CurrentOutboundCard` holds the exit IP, whose refresh button 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
@@ -423,9 +463,34 @@ a new IP and an unchanged IP reuses the result. A lookup races its sources the w
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.
## UI Layer
`lib/ui/` holds the components and design tokens that know nothing of the app: shapes, surfaces, motion, colors, the
scaffold, sheets, popups, lists and the glyphs under `lib/ui/icons/`. `lib/widgets/` holds the components built from
them that do: settings rows, subscription and profile pieces, anything that reads a provider or a model.
`widgets/widgets.dart` re-exports `ui/ui.dart`, so app code imports either barrel.
`test/lint/ui_layering_test.dart` walks the imports of every file under `lib/ui`. Nothing they reach may leave
`lib/ui`, except `lib/common` files whose own imports stay out of the app, never the `common.dart` barrel, and none
may import Riverpod. A component that needs app state reads what the app provides above its navigator:
- `ViewScope` (`lib/ui/view_scope.dart`): the window size, whether the layout is mobile, and the window controls
over a page. `AppViewScope` in `lib/manager/theme_manager.dart` fills it from `viewSizeProvider`,
`isMobileViewProvider` and `versionProvider`. It is an `InheritedModel`, so a reader of one value does not rebuild
when another changes; `context.isMobileView` reads it without listening.
- `MeasureScope` (`lib/ui/measure.dart`): the `Measure` that `ThemeManager` also assigns to `globalState.measure`.
- The `MediaQuery` text scaler that `ThemeManager` sets: the app text scale behind `.ap(context)`, `.mAp(context)` and
the `...Of(context)` paddings in `lib/ui/metrics.dart`. Reading it there makes a widget sized by them rebuild when the
scale changes; a global copy or a top-level final would keep the scale it was first read at.
- `UiLocalizations` (`lib/ui/l10n.dart`): the strings the components show themselves. `AppUiLocalizations.delegate`
in `lib/common/app_localizations.dart` serves them from the ARB strings; a new one needs a getter on both.
A test that builds its own `MaterialApp` registers `AppUiLocalizations.delegate` and returns `wrapAppScopes` from
`test/helpers/test_app.dart` in its builder, as `TestApp` does.
## Icons
`lib/icons/` holds every icon the app shows. Nothing in `lib/` uses the Material icon font, and
`lib/ui/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
@@ -451,8 +516,8 @@ some other state, like the sidebar toggle that tracks the pane's width, is a bui
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
Icons are outlined by default and filled inside a filled button: `IconButton.filled` and `.filledTonal`, and
`FilledButton` in its tonal and icon forms 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
@@ -464,7 +529,7 @@ Framework widgets that draw their own icon take it from the theme: the `ActionIc
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
Empty states draw no image assets either. `NullStatus` in `lib/ui/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
@@ -482,18 +547,227 @@ button while searching) when there is one, then foldable actions, primary action
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
something folds, otherwise the last action, so the proxies list shows a search and delay test group beside the menu.
The bar holds `primaryAction` everywhere, a phone's home page above the fixed navigation bar included, and nothing in
the app floats an action button: a secondary action such as selecting every app goes into `menuItems`. Saving or
confirming, in a page, a side sheet or a form, is a check in `confirmationAction`, never among `iconActions`: it ends
the bar and never folds, and only a form sheet moves it to its foot (Forms). Bar buttons are glyphs with tooltips,
select all included; the one text button is the custom profile's Import, a filled button whose `CommonPopupBox` opens
the import menu. 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.
or clear button, so they never fold. A sheet's close button leads its bar when the bar has actions, except beside a
primary action: then the two end the bar as one button group, as the proxies sheet's delay test and close do.
Actions on what the page edits as a whole, such as renaming the custom profile or viewing its final config, go into
`titleMenuItems` rather than the bar: the title shows a chevron and opens them, as an iOS document's title menu does.
The menu opens from the title's start edge (`PopupPlacement.overAnchorStart`), since the bar's own menus, which open
from their end edge, would cover the back button there.
A bar under a sheet drops its actions and keeps its title and back or close button. `CommonScaffold` fades the actions
out with the entrance of a sheet laid over its page, or over the sheet it is a page of, so none on the layer below
reads as an action of the sheet on top; `sheetCoverOf` in `lib/widgets/sheet_navigator.dart` reads that entrance, which a sheet route hands
reads as an action of the sheet on top; `sheetCoverOf` in `lib/ui/sheet_navigator.dart` reads that entrance, which a sheet route hands
down through `SheetCoverRouteMixin`, as `SnapSheetRoute` and `ModalSideSheetRoute` do.
## Liquid Glass
`ThemeProps.liquidGlass` picks a `LiquidGlassMode`, `none` by default, and reaches widgets as `LiquidGlassScope`, on for
`standard` and `ultra` with that `LiquidGlassQuality`, from `ThemeManager`. With it on, `CommonScaffold` passes `glass`
to the bar's `TonalButtonTheme`,
which puts a `LiquidGlass` pane behind each button through `ButtonStyle.backgroundBuilder` and one behind a
`TonalButtonGroup`; the navigation dock's bar and lifted lens become panes as well, and so do a sheet's docked search,
the cards of a `CommonPopupMenu`, and a `CommonDialog`. On the dashboard only the start button is a pane: its connected
buttons and switches keep their fills, since each pane adds its area to a capture retaken whenever the dashboard
redraws, which it does often. Each still swells on its own under `ElasticPress`, and a `DashboardConnectedGroup` paints
the button pressed last over the neighbours it swells past. `lib/ui/liquid_glass.dart` holds the rest:
- A `LiquidGlassHost` over the app (`lib/ui/liquid_glass_capture.dart`, after g1455's atlas proxy) draws its panes
without reading the backdrop. As the frame is composited, before its children, it redraws onto one small image what
each pane under it would read: the layers painted before the pane, within its reach, shrunk as far as a sigma of the
pane's frost still spans two of the image's pixels, twice where g1455 records its regular finish, and never past
eight screen pixels to one, the coarsest g1455 measured, with that frost blurred in by the engine. A pane without frost, the dock's lens, keeps the screen's resolution, as g1455 keeps its clear finish.
A pane with a `haze` takes a second slot in the same image, shrunk and blurred for its far wider `hazeBlur`, and
mixes the two.
Each pane paints the shader over its part of that image as an ordinary paint, one read a sample where its frost is
even. No pane ends the frame's render pass or copies the surface, and the glass never trails the content by a frame.
A pane outside any host draws only its tint and rim.
- `ultra` draws all of the glass below. `standard` leaves out what costs most for least: a haze becomes the whole
frost, so the dock takes one shrunken slot in place of a sharp one at the screen's resolution, and its text no
longer shows faintly through; a lens's rim parts no colors; the lifted lens's copy goes unwarped, tinted with the
light its pane would add; and the dock casts no shadow, which is under 3/255. The host applies the style changes to
every pane it draws, the dock the rest. A config saved with `standard` before `ultra` existed moves to `ultra` once
(`Migration.upgradeVersion` 2), since that was its look. On the SwiftShader emulator, as raster time a frame over
glass off, median of four alternating runs, the glass before the tiers, `ultra` and `standard` added 16.4, 13.5 and
9.5 ms scrolling, 52, 40 and 17 dragging the lens, 34, 26 and 18 scrolling under an open menu, 22, 13 and 11
opening it and 17, 9 and 5 closing it; scrolling took 18.8 render passes a frame in either tier against 23.6, and
the lens 22 in `ultra` and 18 in `standard` against 30.
- A pane over one painted before it in the same root reads a second image, a level up, with the first drawn in as
glass. Over means within 2.5 sigmas of its frost and as far as it bends, where a pane takes under 1% of what it
reads, rather than the 3 sigmas and 2 dp its slot holds: the bar's buttons stand 8 dp apart, inside the slot of the
overflow button, which went up a level and cost a second image every frame content moved under the bar. A pane's
root is the host or its nearest ancestor that draws offscreen: as a backdrop filter would there, it
sees only that ancestor's content, without the ancestor's own effect, and replaces what it covers. A
`LiquidGlassFade` is no root: as Core Animation fades a layer, its panes still read what lies behind it and fade with
the rest. A level keeps its image while what it drew, the same pictures under the same transforms, clips and
effects, stays the same, so a still screen redraws its glass without a capture and content scrolling under glass
captures once a frame. Within an image each band of slots that blur alike keeps its pixels the same way, copied
from the last image rather than drawn and blurred again, and a slot without frost is a band of its own: dragging
the lens redraws only its copy's slot, not the dock's haze or the buttons' frost. Not on Skia: an image there keeps
what it was drawn from as long as it lives, so each capture copied from the last would hold every one before it,
and memory grows by a capture a frame. The dark line along the rim darkens the blurred capture there rather than the
sharp backdrop, a pixel or two wide.
- Measured on an Android emulator rendering in software (SwiftShader, 1080×2400 at density 420), as raster-thread time a
frame counting the captures that FrameTiming's raster span leaves out, median of three runs, each capture against
none in a run of its own: scrolling Tools takes 43 ms with none, 6 more with a sigma over one pixel of the capture and
11 over three; scrolling the dashboard 64, 9 and 19; switching to Proxies 34, 15 and 25; opening a menu 42, 9 and 13;
closing it 41, 1 and 3; dragging the dock's lens 39, 38 and 42. One pixel adds about half what three do while
content scrolls, and about as much while a page switches, where the capture's size is not what costs, or while the
lens moves, which both capture at full resolution. Three was measured before a menu's capture was held to eight
screen pixels to one, and two, which glass uses, has not been timed. Ultra, which read the backdrop under each pane,
added 27 to 61 ms to the same frames and was dropped. A software renderer weighs passes and texture reads unlike a
phone's GPU, so the figures rank the captures rather than price them; no phone has measured them. A still screen
draws no frames.
- Rendered under `flutter test --enable-impeller` at density 3, against a capture with a sigma over three pixels, one
pixel blurs text under the dark dock into blocks that set 16% of its pixels apart side by side (CIEDE2000 above 2),
and two sets 0.5% apart. Reading a coarse capture through a bicubic filter barely helps: the blocks are in the
capture.
- Where a pane's frost is uneven, as along a rim that softens, the shader reads what the capture does not hold as the
engine's Gaussian, in device pixels, on a Vogel disc of 16 taps up to a sigma of 2 pixels, 32 up to 3.5 and 64
past it, so no taps lie further apart than 64 leave them at the widest; rendered at density 2.625 the lens moves by
at most 12/255 on a few pixels from 64 taps throughout.
- Glass builds no pipelines ahead of use. A warm-up that rendered panes offscreen once the first frame landed was
dropped: after an install, with no pipeline cache on disk, it compiled on the raster thread just as the app was first
used, to spare the first menu and lens only a few pipelines.
- A pane is a rounded rectangle, each corner with its own radius, unless a `clipper` gives it a path, as the start
button's cookie does. The shader reads that path as its reach from the pane's center in 64 directions, so it must
meet every ray from the center once.
- Without Impeller's shader filters (Skia on the Intel macOS build, `flutter test`) a pane frosts from its capture:
blur, saturation, brightness, tint, and a rim, with no refraction; the tint is not thickened there, as it is where a
pane has no capture, since the capture holds the blur. `flutter test --enable-impeller` runs the shader, so `toImage`
shows the real glass.
- A popup grows out of its anchor on a platter (`CommonPopupRoute`). On glass the platter is a `LiquidGlassPane`
easing from the button's `regular` glass to the thicker `menu` glass, and the menu's cards stand `bare` while it
shows, opening and returning, so one pane bends the backdrop at a time. The platter takes the `menu` glass's frost
from the start and, as the cards do, paints with a `hold` over all it may sweep: the host captures that once and
keeps it while the pane moves inside, its frost neither grows nor falls by a tenth, as a folding sub menu's glass
does, and the page holds still, so a menu opening captures three times and closing twice rather than every frame.
On the SwiftShader emulator that cut a menu's frames from 17 render passes to 12.4, against 11.1 without glass, but
saved time only within noise: what glass still adds there, about half again a frame, is the shader over the
growing platter. A held pane casts its shadow after itself, over its capture rather than in it, since a shadow moving with it would
renew the capture. The anchor's snapshot is taken under `LiquidGlass.withoutPanes`: offscreen a pane has no
backdrop and would come out a flat tint. A menu opened at a point fades out under the layer a `LiquidGlassFade`
pushes, so its glass keeps reading the page as it goes.
- An alert on glass takes the `alert` glass, the `menu` glass frosted wider, under its `AlertDialog`'s surface, which
it finds as the dialog's `Material` lays it out, since a dialog takes only a color. Its route fades it under a
`LiquidGlassFade`, so the glass keeps its full blur and fades with the text, as UIKit fades an alert and its glass on
one layer. Its plain buttons and fields fill with `glassFillOf`, black or in dark mode white at 13%: iOS 27 shifts
them from the glass under them by about 30/255, darker or lighter, a blend Flutter has no mode for, which 13% meets
over the glass an alert shows on a page. Its route dims by `alertScrim`, iOS's fifth in light mode and near half in
dark mode, not Material's third, so the glass reads the backdrop iOS's reads; under Material's a light alert came
out grey. Measured against UIKit over the same page, the title sits 21 dp down, a message 6 dp under it and a field
16, the buttons 20 under either; plain button labels take the medium weight, and a light message takes
`onSurfaceVariant` at 85%, as light as iOS's vibrant secondary label. UIKit sets a title with no message centred in
the body weight, so an alert with fields (`hasFields`) does too, with room for a trailing control's width kept on
both sides to hold the title on the alert's centre. A destructive action takes `ColorScheme.destructive`, iOS's
systemRed: Material's `error` read dull in light mode and pink at tone 80 on dark glass.
- A press anywhere on an alert swells the whole alert, glass and content, as UIKit swells it, measured at 4 pt on its
320 width, and a drag past its edges stretches it about 3 pt; the button under the finger does not swell, it fades
its fill to 40%, or a dark default button's to 50%, while a dark plain button keeps its fill. `ElasticPress` takes
the swell and the pull in dp and finds the surface through `panelOf`, since an `AlertDialog` spans the screen
around it.
- The `menu` and `alert` glass follow iOS 27's menus and alerts as the simulator showed them over solid, striped and
split grounds, keeping the scheme's tint: in light mode the glass passes the backdrop at 45%, doubled in saturation
and a tenth brighter, within 2/255 of iOS; in dark mode iOS keeps each colour's hue and chroma while it compresses
white to mid grey and lifts black to 32/255, which the shader matches over solid grounds, within 4/255 and 11 at
worst, by thickening the scheme's highest container over a light backdrop. The dark fit weighs black and dark grey
most, the grounds a dark page puts behind it. That `contrast` reads the whole pane's backdrop, not each pixel, so
over half black, half white the white half shows lighter than iOS's. It reads five points, each as four taps a frost
sigma apart so that a glyph passing under one does not flicker the tint, but once under a haze or a frost of 8 dp
or more, which blur a glyph past flickering. iOS frosts an alert at a sigma of about 13 dp
and a menu at 10 to 13, with no sharp share like the dock's.
- The `menu` glass leaves its rim to a `LiquidGlassRim`, which alerts, menu cards and the platter as it lands draw
around their panes: the line on the canvas outside the pane, and the highlight in the pane's shader, band by band
and turn by turn as the fallback draws it in nine even-odd rings, within 12/255 of those. It is shaded unlike the
bars' rim: as iOS 27 draws it around both, measured in the simulator, the dark
line lies just outside the glass, darkest along the sides, and the highlight is light added to the glass, brightest
at the middle of the top and bottom, about half as bright where a corner begins, a fifth where it ends and gone
down the sides, fading over about 5 dp inward; a menu's line is lighter than an alert's. A glass menu and a glass alert cast iOS 27's shadow as
measured over white, a deep one under a wide one, half again as dark in dark mode, where a solid menu keeps its
softer one.
- The optics bend with a circular bevel whose width and depth shrink to fit a small pane, so the rim shows the content
squeezed and mirrored. The materials are iOS 27's as codenameone/CodenameOne#5906 fitted them (`pill` for the dock,
`regular` for buttons): the backdrop passes through as `offset + scale * saturated backdrop`, the tint at
`1 - scale` carrying the offset; a dark line takes the outermost pixels because UIKit draws it just outside the
bounds, on the dock as faint all around as iOS draws it along the top and bottom, since the darker sides iOS draws
read as brackets on a pale page; a highlight lights the top and bottom, fading over 1.3 dp and, as iOS 27's tab bar
fades it around its rounded ends, with the square of the normal's upward part; the lifted lens keeps a narrower band.
Buttons (`regular`, `tinted`) take the rim of iOS 27's glass and bar buttons, measured in the simulator: the line
darkens toward the sides with the cube of the normal's sideways part, from nothing along a dark button's top and
bottom, and the highlight fades over 1.2 dp with a sixth of it in a tail to 4.5 dp, dims round the ends far faster
than the tab bar's, and is partly added as light, so it lifts a pale button as iOS does.
The line and the highlight keep to the superellipse the pane is clipped to, which the shader traces after the
engine's `round_superellipse_param.cc`: the circles the optics bend by lie up to half a dp outside it where a long
side runs into its end, and a line on them breaks there. A test holds the traced curve to the engine's path.
In dark mode the tint is
the scheme's `surfaceContainerHighest` at iOS's alpha: iOS's mid grey lifts a dark page into a grey haze. The tint
thickens over content much darker than it, or with a negative `contrast` much lighter.
- The `pill`'s frost, line and shadow follow iOS 27's tab bar at rest, measured in the simulator over solid fills and a
black and white split: 28% of the backdrop shows sharp and 72% through a blur of 15.6 dp, so a stripe under the bar
spreads over its neighbours; iOS mixes the two brighter toward white, as if it blurred in linear light, which the
engine's blur cannot. Dark mode draws no line, as iOS draws none along a dark bar's top and bottom. Glass bars cast
1.3% black blurred 3.8 dp and dropped 6.5 dp, shading only below and by 3/255 at most, in both modes; the solid
dock keeps its wider shadow. The sharp part needs no blur pass of its own, so on the SwiftShader emulator the haze
added half a render pass a frame scrolling Tools, about four on the dashboard, where it no longer shares the start
button's blur, and no time beyond the noise of three alternating runs. Its colors are deliberately not iOS's: iOS's
bar takes no `contrast`, its selection platter is a color matrix of the bar and its icons are vibrant, where the
dock keeps the theme's.
- The dock and its lens follow UIKit's tab bar as codenameone/CodenameOne#5906 measured it frame by frame: 86 dp a
destination, a resting lens 8 dp wider, a lift of 16 dp that magnifies what lies inside the lens by 16%, and springs
taken from the capture; the lens's stretch and squash are a fit to its deformation tables. The lift stays up until it
has risen fully and the lens has arrived, so a tap pops it all the way, and `ElasticPress` likewise finishes a tap's
swell on the bar's press spring before it bounces back. The destinations stand on the lifted lens, as iOS draws
them, so it bends only the bar; what lies inside it is copied, tinted and magnified, into an offscreen layer where
a second clear pane warps it toward the rim on its own, as liquid_prism read UIKit's displacement inputs on iOS 27.
The lens follows what iOS 27 shows of it over ramp and line backdrops in the simulator: it reads outward along its
normal all around, by nothing at the rim and 4.3 dp a 5.5 dp bevel in, easing off toward its middle, so a band
rings it with what lies right under its rim, drawn out; red and blue part by up to 6.5 dp along its top and bottom
and about 3.9 dp around its ends, across the band's outer 7 dp. In dark mode both panes brighten what they show by
a third, as iOS does, and the lens casts a soft shadow 8 dp down. A press lights the bar through `LiquidGlass.glow`:
a wash over the whole pane, brightest at the finger, that spreads out as it fades. A `pressed` pane, as a glass
button's or a `TonalButtonGroup`'s is, hears the pointer on what it wraps and lights from the finger the same way.
## Popup Motion
`CommonPopupRoute` follows WWDC25's account of iOS 26 menus: one that grows out of a control keeps the two continuous,
so closing returns it into the control on the same platter, its content blurring out first. Apple documents only the
opening; one opened at a point (a context menu) has no control to return into and shrinks toward the point to
`_dismissScale` as it fades, as iOS context menus do.
A sub menu is a `PopupMenuCard` laid out once at full size. One spring (`_unfold` in `lib/ui/popup.dart`) moves
its surface and clip from the item that opened it to its bounds, recedes the parent, and reveals the items, so a fold
caught mid-unfold turns back with the speed it had. The parent's item hides under the sub menu's header, which draws
it again, and the sub menu is removed only once it stands on that item.
A `CommonPopupBox` opens on its button's pointer down, as an iOS menu that is a button's primary action does, and the
press carries on into the menu. `_PopupPressRecognizer` claims the pointer then, so the button's own tap and its
tooltip's long press stand down. In a scroll view, as the settings rows are, it first waits `kPressTimeout`, as iOS
scroll views hold back a touch, so a drag from the button scrolls and a quicker tap opens the popup on its release. The
pointer's moves and release keep reaching the button, so the box hands them on (`_CarriedPress`) and lets go of the
`ElasticPress` around it. The menu follows them only once they leave the slop, since it covers the button and a press
let go where it went down would pick what now lies under it, and drives its own `ElasticPress` with them through
`ElasticPress.maybeOf`. The box tells a disabled button by the `onTap` of its first `InkResponse`, and holds the popup's
`anchorShift`, since it opens before the button's `onPressed` would run.
The menu stands in an `ElasticPress` of its own with `pullPastEdges`: it swells under a press and stretches only toward
a finger that strays past it, so sliding over the items or scrolling leaves it in place. A press on a row belongs to the
menu rather than the row's `InkWell`, which keeps only hover, focus, and keys: `_RowPressRecognizer` highlights the row
under the finger as it slides, hit testing where the menu paints (`ElasticPress.paintBoundsOf`), with a selection click
on each change, and picks the one it is let go on, or none off the rows. It claims the pointer only on release, so a
menu long enough to scroll takes a drag first and drops the press. A press that rests for `_dwellDuration` on an item
with a sub menu opens it, and one that rests on an open sub menu's header folds it back, each with a click as the
highlight moves; the finger then stands on the header or on the item it folded into, and letting go there changes
nothing. A tap that opens or folds a sub menu clicks no more than a tap on any item does, as iOS menus give selection
feedback only to a sliding press.
## Row Actions And Sort Mode
A list row or card carries no trailing more button. Its secondary actions go into a `ContextMenuRegion`, which opens
@@ -536,9 +810,9 @@ few keys of a `tun` section the network settings own, so its spec does not mix i
## Where A Route Opens
On desktop each navigation destination has its own `Navigator` (`_NavigationPage` in `lib/pages/home.dart`); on mobile
there is none, and everything goes on the root navigator, `rootNavigatorKey` in `lib/common/navigator.dart`. A route
takes the navigator of the context it opens from: `BaseNavigator.push`, `showExtend`, `showSheet` and `showSnapSheet`
all do, so a page or sheet opened from a destination stays beside the navigation rail, leaves the rail usable, and
there is none, and everything goes on the root navigator, `rootNavigatorKey` in `lib/ui/navigator.dart`. A route
takes the navigator of the context it opens from: `BaseNavigator.push`, `showExtend`, `showSheet`, `showFormSheet` and
`showSnapSheet` all do, so a page or sheet opened from a destination stays beside the navigation rail, leaves the rail usable, and
stays in that destination's stack when the user switches away and back. `sheetNavigatorOf` lifts a sheet out of a
paged sheet's own `SheetPagesNavigator`, never past the destination.
@@ -570,14 +844,50 @@ rebuilds one row instead of the whole section. Titles and subtitles are `ConfigL
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
`ConsumerWidget` only when a row is genuinely reused across screens. Rows with bespoke behaviour — a custom dialog, a derived value, or a
second provider write — stay hand-written rather than growing extra parameters on the shared items.
In an inset grouped list (`generateSectionV3`, `generateAnimatedSection`), a row's subtitle shows its value or state,
In an inset grouped list (`generateSection`, `generateAnimatedSection`), a row's subtitle shows its value or state,
never what the setting does. As iOS settings do, the explanation goes in the group's `footer`, drawn by `ListFooter`,
one sentence per setting that names it, and a section header carries only its title. Flat `generateSection` lists keep
their descriptions on the row.
one sentence per setting that names it. A section header carries its title and at most a few actions on the group, as
`IconButtonData`: `ListHeader` draws them as 36 dp tonal buttons (`TonalButtonSize.section`), the first two grouped,
centered on the title. Every header stands as tall as one with a button, 6 dp clear of the group below, so headers
match whether or not they carry actions. A group with a selection swaps them
for delete and select all, never a text button. `generateSection` is the only section builder, and a sheet lays its groups inside the 16 dp inset the pages use.
A row that opens a page, `ListItem.open` or `ListItem.next`, ends in `AppGlyphs.chevronForward` unless it passes its own
`trailing`, as iOS settings rows do. A row that opens a dialog in place, `ListItem.input`, has no chevron. A setting
that takes one of a few values is an iOS pop-up row, `ListItem.options` or `ConfigOptionsItem`: it ends in its value and
`AppGlyphs.picker`, and a tap anywhere on it opens a `CommonPopupMenu` out of that value with the current option
checked. Its items are text alone, as an iOS picker's are; glyphs belong to menus of actions. One value is never picked
from a radio dialog or a row of selectable chips; a button that picks one, as the theme's scheme variant does, opens the
same menu through a `CommonPopupBox`. A pick from a menu that returns into its control runs only once the popup has
landed, so a setting that rebuilds the app, such as the language, never does so under the returning platter; the
control shows its value with `PopupValueText`, which blurs across to the new value while the control eases to its
width. A menu opened at a point, as a context menu or the editor's is, runs its pick at once. A menu capped by
`maxVisibleItems`, such as the proxy group tabs' more menu with every group in it, builds only the rows in view and
takes its width from its labels measured as `EmojiText` lays them out.
## Forms
A form that edits more than one value opens with `showFormSheet` (`lib/ui/sheet.dart`): a bottom sheet in the mobile
layout, and elsewhere a card `FormSheetRoute` centers over the navigator, as iPad and macOS present a form sheet, moving
as an alert does. Both give the page `SheetType.bottomSheet` or `SheetType.formSheet`, compact sheets that share
`CommonScaffold`'s sheet bar, so one page serves both: `FormPage` for a plain form, or the page a `NestedPagedSheet`
pushes for the custom profile editors. Its values sit in `FormRow`s inside `generateSection` groups, each field a
`FormRowTextField`, bare and set against the end in both, so a focused field's only outline is its row's focus ring.
`RenderEditable` ends a field's text short of its end by the caret's margin, so the field ends its hint as short and
reaches as far into the row's padding: the caret follows the hint or the text, and the text ends where another row's
value does. The
save is the scaffold's `confirmationAction`: last in a bottom sheet's bar, and in a form sheet the filled default beside
Cancel, which takes the close's place, in a row of alert buttons at the first page's foot, as SwiftUI places a
confirmation action on macOS; other actions, such as quick edit, stay in the bar. A row cannot hold an error line, so a
save the form refuses marks the row `invalid` and says why in the `ErrorBanner` above the groups, which follows each
edit from then on. A prompt for one value, such as a URL import, a rename or a password, stays an alert (`CommonDialog`,
`InputDialog`), as on every Apple platform; a key with its value, as `MapEditView` adds, is two values and a form.
An alert raised while focus is in a form sheet opens on the sheet's
navigator, centered on the card rather than on the window: `Dialogs` follows the focused route when it is an
`AlertHostRoute`, which the form sheet and every alert are.
## Keyboard Insets
@@ -587,48 +897,115 @@ which runs every one of those rebuilds inside the layout phase; in a profile tra
`BUILD`.
- A page the user is not typing in holds its bottom inset through `KeyboardInsetHold`
(`lib/widgets/keyboard_inset_hold.dart`). Pages under another route and inactive tabs are still laid out, offstage or
(`lib/ui/keyboard_inset_hold.dart`). Pages under another route and inactive tabs are still laid out, offstage or
not, and without it every page the app keeps relaid out, and rebuilt under its `LayoutBuilder`s, with each frame of a
keyboard raised by a dialog or sheet on top. The home route, each home tab, `CommonRoute`, `CommonDesktopRoute` and
the open-container route wrap their page in one; a new page route type does the same.
- A widget high in the tree reads the aspect it needs (`MediaQuery.paddingOf`, `viewInsetsOf`, ...), never
`MediaQuery.of` or `MediaQuery.removePadding(context: ...)` on its own context, which subscribes it to every inset
change. Move such a read into a small widget of its own below it, as `_AppMediaQuery` in `ThemeManager`,
`_BodyPadding` in `lib/pages/home.dart` and `_SheetKeyboard` in `SnapSheet` do.
- A bottom sheet makes room for the keyboard itself: `_SheetKeyboard` lays the content out above it and removes the
bottom inset from the content's `MediaQuery`, so nothing inside a sheet, `CommonScaffold` included, adds keyboard
space. A nested sheet must not carry the keyboard through its pages: `NavigatorResizable` learns a page's new size a
frame late, and the sheet would trail the keyboard by a frame.
`_BodyPadding` in `lib/pages/home.dart`, `_KeyboardViewport` in `lib/ui/snap_sheet.dart` and the `SafeArea` around
the docked search field in `CommonScaffold` do.
- A bottom sheet makes room for the keyboard itself: the keyboard raises the sheet's foot (Bottom Sheet Motion), and
`_WithoutKeyboard` removes the bottom inset from the content's `MediaQuery`, so nothing inside a sheet,
`CommonScaffold` included, adds keyboard space. A nested sheet must not carry the keyboard through its pages:
`NavigatorResizable` learns a page's new size a frame late, and the sheet would trail the keyboard by a frame.
- The keyboard moves a bottom sheet as it is painted. `_RenderSheetViewport` lays the surface out as tall as the view
whatever the keyboard, and a fit sheet keeps the limit its short content was laid out within while the keyboard
changes the room above it, until the content reaches either (`releaseLimit`). A keyboard frame then lays out and
paints nothing inside the sheet unless what it shows changes.
- A field without a label says `floatingLabelBehavior: FloatingLabelBehavior.never`, as `FormRowTextField` and
`SearchField` do. `InputDecorator` animates the floating label of an empty field on focus even when there is none,
`InputDecoration.collapsed` included, and each frame of it lays the decorator out and, inside a sheet, the whole
form, through the first frames of the keyboard.
## Rebuild Scopes
A `LayoutBuilder` runs the rebuilds of everything under it inside its own layout, so a widget rebuilding there marks
the builder for layout, and that climbs to the nearest relayout boundary. Flutter marks every render object it lays
out for paint, so the nearest repaint boundary above that path repaints too. Under the material `Scaffold`'s body
`LayoutBuilder` (it has one with `extendBodyBehindAppBar`) that was the page and its background, and under the home
shell's `LayoutBuilder` the whole home route: a spinner turning in a page, or the dock's lens moving, relaid out and
repainted the page on every frame, and the glass over it captured every band again, the bar's included, though
nothing under the bar had changed.
So the body and the dock each take their rebuilds in a scope of their own: a `LayoutBuilder` under a repaint boundary
that is also a relayout boundary. `CommonScaffold`'s `_BodyScope` sizes itself to the most the scaffold allows, which
the scaffold never reads back, and lays the body out under it as before; `NavigationDock` sizes itself as it always
was, full width and its bar's height, and lays its boundary out tight. A rebuild inside either relays out and
repaints only that layer.
## Bottom Sheet Motion
`SnapSheetRoute` follows `UISheetPresentationController` as measured on the iOS 27 simulator (a UIKit probe app driven
by the simulator's touch injection, logging the presented view's presentation layer every frame).
- One spring moves a sheet: UIKit's mass 3, stiffness 1000, which runs critically damped (ω ≈ 18.26 rad/s, done in
about 0.5 s). Presenting, closing, a detent change and a spring back all trace it within a point. A release thrown
toward its target keeps its velocity and lands with UIKit's slight overshoot, about damping ratio 0.8 at full
strength; a release away from its target loses its velocity and lands dead.
- A release lands on the detent, or the dismissal, nearest to where `UIScrollView.DecelerationRate.fast` projects it.
With no velocity a sheet closes once it is let go past half of its shortest detent; a flick of 1000 pt/s 85 pt
down springs back on iOS, one of 1500 pt/s closes.
- The sheet follows the finger one to one below its shortest detent and stops at its tallest, with no stretch.
- On the content, a drag up grows the sheet first and scrolls the content with what is left, in one gesture. A drag
down moves the sheet only when it began with the content at its top; one that scrolls the content back to its top
stops there, as iOS bounces it rather than handing it to the sheet.
- The keyboard raises the sheet's foot: a detent keeps what it shows above the keyboard, as a custom detent does on
iOS, up to a peek below the top. iOS lifts a medium sheet to its large detent instead, which a phone's keyboard,
about as tall as the gap between them, makes nearly the same. Since the sheet's height is a function of the inset,
it moves with the keyboard on every frame and lands back where it was.
- Where the keyboard starts or stops carrying the sheet, that function rounds its corner (`_rounded`), so the sheet's
speed changes smoothly. With a sharp corner a sheet stood still while the keyboard slid over the blank below short
content and then jumped, and on the way down halted in mid-flight; the tallest detent hit the peek at the keyboard's
full speed. Short content now eases into the rise over half its blank, which can leave the sheet up to an eighth of
the blank higher than its content needs, and the tallest detent slows into the peek over as much keyboard again as
it takes to get there, up to `_peakEase`.
`_SheetModel` keeps the sheet as an activity, resting at a detent, held at a height or settling toward a detent as an
offset from it, and layout resolves the detents again every frame from the view, the keyboard and the content. A
resting or settling sheet therefore follows a page transition's size or the keyboard without being told, where a
stored height would have to be patched after each change. Below the shortest detent a drag moves the route's own
animation, so the barrier, a sheet stepped back behind this one, and the page bar under it follow the finger, and the
close continues from the release on the presentation spring (`createSimulation`).
## Page Arrival
A pushed page is built, laid out and painted through every frame of its transition, so whatever it starts on arrival
(loading and decoding images, building the rows its data brings in) lands inside the animation. Impeller has no
raster cache, and every offscreen pass is paid again each frame on a phone GPU.
A pushed page is built, laid out and painted through every frame of its transition. Impeller has no raster cache, and
every offscreen pass is paid again each frame on a phone GPU. Even so, a page loads what it shows as it mounts: content
held back until the route settles slides in as an empty page and pops in once the motion has stopped, which reads worse
than the frames a load costs. What a transition must not carry is work that repeats on every refresh or every frame.
- Work a widget would start as it mounts waits for the route to settle. `RouteSettledMixin`
(`lib/widgets/route_motion_hold.dart`) calls `didSettleRoute` once the route that brought the page in has arrived,
and at once on a page that already sits still; `whenRouteSettled` (`lib/common/navigator.dart`) does the same for a
one-off future, and both wait out the frame a `HeroController` builds a pushed route offstage with its animation
pinned to completed. `CommonTargetIcon` and `PackageIcon` show what is already in memory at once and load the rest
after that. An icon still loading leaves a blank slot of its size and shows its fallback only once the load ends
without an image, so a grid of placeholders is never painted through the transition only to be replaced.
- A scroll view of image tiles takes `arrivalScrollCacheExtent(routeSettled)`: the rows past the viewport would
otherwise be built in the same frame as the ones on screen, which for a screen of icon cards is most of the push.
The icon grids (`IconGridScrollView`) and the access control list do this, and so does `ListEditorPage`, whose
rows are heavy enough that only its list rebuilds as the route settles, reusing the rows already built.
- A page whose content waits on what it loads on arrival (lists read from the database, file sizes, the installed fonts)
shows its `NullStatus` illustration, with no label, inside a `NullStatusSwitcher` until all of it is in, then switches
to the finished content in one step, as the custom profile, resources and font family pages do. Never a skeleton
screen of placeholder rows: it has to guess how many rows are coming, fills in piece by piece, and rebuilds every row
it stood in for. `SkeletonText` is for a single value inside a card that is already on screen, such as the dashboard's
service status. `holdsArrival` keeps the status a switcher opens on for at least 400 ms, about a route transition,
so a load that finishes just after arrival does not flash it; the connections, resources and font family pages set
it.
- Loads start as the widget mounts, never behind `whenRouteSettled`. `CommonTargetIcon` and `PackageIcon` show what is
already in memory at once and load the rest right away; the editor reads its file and mounts CodeForge as soon as the
content is in. An icon still loading leaves a blank slot of its size and shows its fallback only once the load ends
without an image, so a grid of placeholders is never painted only to be replaced. `whenRouteSettled`
(`lib/ui/navigator.dart`) is left for sequencing navigation, such as opening a page once the sheet above it is gone.
- A live feed holds its refreshes while its page moves and replays the latest once it is still, so a list does not
re-rank, reorder or grow under a transition. `RouteMotionHoldMixin` (`lib/ui/route_motion_hold.dart`) holds them
while `isRouteMoving`: the page's route or a sheet carrying it is pushed, popped or dragged, a page is pushed over it,
or a sheet covers it (`sheetCoverOf`). The connections, requests, logs and DNS pages and the dashboard's live
connections table use it. A feed's first content is no refresh and shows at once, as `ConnectionsView` applies its
first poll, and a held poll keeps the time it was read at, which `TrackerSpeedRanker` derives speeds from. Per-frame
work spread over frames pauses the same way: the font family page switches one name to its own family per frame and
stops while `isRouteMoving`.
- A scroll view of image tiles takes `arrivalScrollCacheExtent(routeSettled)` from `RouteSettledMixin`: the rows past
the viewport would otherwise be built in the same frame as the ones on screen, which for a screen of icon cards is
most of the push. The icon grids (`IconGridScrollView`) and the access control list do this, and so does
`ListEditorPage`, whose rows are heavy enough that only its list rebuilds as the route settles, reusing the rows
already built.
- A page whose content waits on what it loads (lists read from the database, file sizes, the installed fonts) shows
nothing until all of it is in, then the finished content in one step: `NullStatusSwitcher.isLoading`. A load that can
run long also gives the switcher a `loadingStatus`, its `NullStatus` illustration with no label, which takes the
place of a first load still running after about a route transition (400 ms); one that ends sooner goes straight to
its content, so the illustration never flashes. The custom profile, resources and font family pages set one. Never a
skeleton screen of placeholder rows: it has to guess how many rows are coming, fills in piece by piece, and rebuilds
every row it stood in for. `SkeletonText` is for a single value inside a card that is already on screen, such as the
dashboard's service status.
- `GlyphPainter` paints a translucent glyph through an offscreen layer. A placeholder repeated in every tile blends its
color over the surface instead of carrying an alpha.
- `PagedSheetRoute` and `CommonRoute` share
`SharedXPageTransition` (`lib/widgets/page_transition.dart`) and its `pageSlide`, a quarter of the page's width as
`SharedXPageTransition` (`lib/ui/page_transition.dart`) and its `pageSlide`, a quarter of the page's width as
Android 14 slides pages, so a page moves alike in a sheet and on its own. The transition fades a page by painting the surface over it
and needs no opacity layer: the page on top shows only on its side of the point where the two fades cross, opaque
over the other, so neither shows through the other. A page below that does not fade with it, such as the home
@@ -690,15 +1067,17 @@ itself live in one. Code without a `Ref` reaches providers through
path that never validates never resolves the controller.
The UI layer must not reach a process-wide singleton directly.
`test/lint/ui_layer_singleton_test.dart` scans `lib/views`, `lib/widgets`,
`test/lint/ui_layer_singleton_test.dart` scans `lib/ui`, `lib/views`, `lib/widgets`,
`lib/pages` and `lib/features` for `globalState.container` and the bare
`coreController` global and fails the run on either. Widgets that need Core hold
`CoreController get _core => ref.read(coreHandlerProvider)`.
`globalState.measure` and `globalState.theme` stay global on purpose. Both are
context-derived caches assigned by `ThemeManager`, and tests already scope them by
assigning in the app builder (see `test/helpers/test_app.dart`); moving them into
providers would touch every layout call site without changing behaviour.
assigning in the app builder (see `wrapAppScopes` in `test/helpers/test_app.dart`);
moving them into providers would touch every layout call site without changing
behaviour. `lib/ui` cannot read `globalState`, so it takes the same `Measure` from
`MeasureScope`; see UI Layer.
`globalState` in `lib/state.dart` is a singleton holding ambient app state — the
package info, the measure and theme, the container, and the start/stop flags —
@@ -707,7 +1086,7 @@ plus `safeRun`/`loadingRun`. Startup orchestration is **not** on it: `init` and
window, the autostart entry, the tray and the permission prompts. Providers are
generated into `lib/providers/generated/`.
The root navigator key lives in `lib/common/navigator.dart` as `rootNavigatorKey`;
The root navigator key lives in `lib/ui/navigator.dart` as `rootNavigatorKey`;
`globalState.navigatorKey` is a getter onto it. `lib/common/dialog.dart` reaches
the key directly and does not import `lib/state.dart`.
@@ -736,8 +1115,8 @@ The shapes that keep the closure small:
- `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
`common/print.dart` and `common/request.dart` import `state.dart` for
`container` and `packageInfo`/`ua`, so anything
`GlobalState` reaches lands in the barrel's closure; the ambient state it
holds reaches nothing platform-specific.
- `SystemAction` talks to `WindowPort` and `TrayPort` from
@@ -755,9 +1134,8 @@ The shapes that keep the closure small:
`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.
is plain data, since it is persisted in the app settings; the card mapping
lives in `lib/views/dashboard/widget_registry.dart`.
`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
@@ -795,7 +1173,8 @@ seen. There are three levels of gating:
`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.
`resumed`, its home page is the current one, and no opaque route covers its page: the overlay turns tickers off on a
covered page once the route over it has finished moving in, and the mixin follows `TickerMode`.
- `_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.
@@ -1147,22 +1526,6 @@ NDK version stands in for the hook's cache.
`com.follow.clash.core.mobile.**`, the classes gobind's C glue looks up by name.
- The CI `android` job compiles `:core` without Flutter, so it passes `-Ptarget-platform=android-arm64` to build one ABI.
#### Android Crash Symbols
Crashlytics NDK symbolicates a native crash only from libraries that still carry their symbols, and AGP strips every
`.so` it packages, so the Android build keeps symbols until `:app`'s own strip step and the APK does not grow:
- `bindCore` drops `-s` and `-w` from `go_ldflags`, and gomobile strips nothing itself; the desktop Cores stay
stripped because nothing consumes their symbols.
- `:core` sets `packaging.jniLibs.keepDebugSymbols` so its own strip task leaves `libclash.so` intact on the way into
`:app`'s merged native libs, which is where the Crashlytics plugin reads it.
- `:app` turns on `nativeSymbolUploadEnabled` and finalizes `assembleRelease` and `bundleRelease` with
`uploadCrashlyticsSymbolFileRelease` only when release signing is configured, the same condition that marks a real
release build; the checked-in `google-services.json` is a placeholder, so a local upload is noise at best, and CI
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.
@@ -1183,6 +1546,9 @@ Crashlytics NDK symbolicates a native crash only from libraries that still carry
concern into `lib/code_forge/controller/` extensions. `CodeForge` builds `_CodeFieldRenderer`, whose painting, layout,
folding, caret, gutter and pointer code sit in `lib/code_forge/code_area/` part files.
- `loadText` builds a document of 128K characters or more on a Rust worker thread (`RopeBridge.createInBackground`),
so a large file does not stall the frame it arrives in; `EditorView` loads through it, and a later `text =` or
`loadText` supersedes one still building.
- 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 learns what changed from `takeChange()`, whose `lines` (first line, lines removed, lines inserted) let it
+37 -5
View File
@@ -51,7 +51,7 @@ hooks:
```
CI flips `setup` to `false` with `yq` in its throwaway checkout before
`dart run tool/changelog.dart` and `flutter test`; no test loads the Go core. `rust_api`
`dart run tool/changelog.dart` and `dart run tool/test.dart`; no test loads the Go core. `rust_api`
stays on there because the editor tests in `test/plugins/code_forge/` and the socket
test in `test/core/desktop/ipc_native_test.dart` load the Rust library the hook
builds; the release-notes job, which runs no tests, flips both.
@@ -154,12 +154,29 @@ flutter test test/setup_test.dart
flutter test plugins/proxy/test/proxy_test.dart
```
For the whole suite, or a directory of more than a few files, use `tool/test.dart`, which is also what CI runs:
```bash
dart run tool/test.dart # all of test/
dart run tool/test.dart test/widgets test/views # some paths
dart run tool/test.dart -- --reporter expanded --coverage
```
Plain `flutter test` compiles each file into its own program and starts an engine for it, one file after another, and
that dominates a full run: 329 files took 128 s on a 12-core Mac with at most four running at once. `tool/test.dart`
imports the files into 32 generated entrypoints under `.dart_tool/test_bundles/` and runs those with one worker per
core: about 60 s for the same run, and 67 s instead of 180–213 s with `--coverage`. Files in one entrypoint share an
isolate, so a test can see state a file before it left behind; the script runs every file that failed there again on
its own, lets that run decide, and names the files that only failed together. See the Shared Test Isolate section of
`rules.md` before fixing one.
`plugins/setup/setup_hooks/` is a pure Dart package and is tested with `dart test` from its own directory, which is
also what CI runs; `tool/check_plugins.sh` does not descend into it. It must stay out of `flutter test`, because a test
run of `plugins/setup` would execute the build hook itself.
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.
`flutter build <platform> --debug` on every branch push, and by the release matrix on a tag; `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`.
@@ -338,9 +355,11 @@ directories: `plugins` (local Flutter packages and the setup build tool), `go`
(the Core wrapper, plus a vet of the Android files), `android` (JVM unit tests
for `:common`, `:service` and `:app`, with the Flutter compile tasks excluded
and the gomobile AAR built for one ABI), `rust` (both crates), and a
Windows runner for the helper's `windows-service` feature. A `desktop` job also
compiles a debug build per desktop platform. Release builds run only in the
public repository and start once every job except `desktop` passes.
Windows runner for the helper's `windows-service` feature. On branch pushes a
`desktop` job also compiles a debug build per desktop platform; a tag skips it,
since its release matrix compiles the same native code. Release builds run only
in the public repository and start alongside the checks; `upload` publishes
only once every build and every check job 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
@@ -348,6 +367,19 @@ It discovers every `plugins/*/pubspec.yaml`, analyzes each package, and runs
no workflow edit; enumerating packages by hand in the workflow is what
previously left `plugins/tray` unanalyzed and untested.
### CI Caches
A run restores only cache entries saved on its own branch or tag, or on the default branch (`main`), and the
repository holds 10 GB before evicting the least recently used entries. Every release is a new tag that no later run
reads from, so `BRANCH_CACHE` turns caching off in tag runs: one release used to write about 9 GB of such entries and
evict everything `dev` had saved. Release builds therefore start cold; branch runs, chiefly `dev`, keep the caches.
- setup-go keys its entry on the `cache-dependency-path` files alone and saves only on a miss, so jobs listing the
same files share one entry holding the build cache of whichever finished first. `desktop` and `android` each add
the file that drives their Core build to get an entry of their own.
- setup-gradle writes only on the default branch unless told otherwise, so `android` lets `dev` write as well. Its
entries are keyed by job, which is one more reason the release build gains nothing from caching.
## Worktree Tooling
```bash
+10
View File
@@ -91,3 +91,13 @@ listed in the SDK's `CHANGELOG.md`.
- **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`.
## Liquid glass saves around the transform of a blurring layer
- **Bug:** not filed upstream. On 3.47 Impeller scales a `saveLayer`'s image filter by a transform set inside the
layer that is still in effect when the layer is restored. A host's capture shrinks each pane's slot inside the
layer that blurs it, so its blur vanished at a quarter scale and spread half again as far at 1.5.
- **Workaround:** `_LiquidGlassHostLayer._snapshot` sets each slot's transform inside a `save` of its own, and each
band's offset, restored before the layer is.
- **Remove when:** without that `save`, the frosted-pane case of `test/widgets/liquid_glass_host_test.dart` passes
under `flutter test --enable-impeller`.
+73 -17
View File
@@ -56,12 +56,41 @@ with an enclosing clip or shape where it shows. Framework widgets that paint the
decoration to override, such as the `Switch` track, are left as Material draws them. Tooltips take theirs from
`withAppShapes`, which sets `tooltipTheme.decoration`.
### Typography
Text sizes follow Apple's iOS Dynamic Type scale at its default size, on every platform. `AppTextStyle` in
`lib/ui/typography.dart` holds its eleven styles, `largeTitle` to `caption2`, with Apple's leading and weights, plus two
steps the scale lacks: `display` for a dashboard figure and `pageTitle` for an app bar. Take one with
`AppTextStyle.body.of(context)`, pass `emphasized: true` for the scale's emphasized weight (bold for the titles,
semibold for the rest), and change color or family with `copyWith`; a weight is either regular or emphasized. Text
sized to the shape it marks, such as a letter scaled to its icon, comes from `AppTextStyle.custom`. The dashboard's
roles (`DashboardText` in `lib/views/dashboard/style.dart`) are built on the same types. `ThemeData.withAppTypography`
maps Material's `TextTheme` slots onto the scale so Material components follow it; app code never reads those slots.
App bar titles sit at the leading edge in `pageTitle` on a root page and on any desktop page, at the leading edge in
`headline` on a pushed page in the mobile view, and in the bar's centre in `headline` on a bottom sheet. A title widget,
such as the editor's name field, stays at the leading edge. Past a back button a title keeps the bar's action inset
clear of the button on either side.
`test/lint/typography_test.dart` fails on `textTheme` anywhere in `lib/` but `typography.dart`, and on `fontSize:` or
`fontWeight:` outside `lib/ui/`, except in the code editor, whose text follows the editor font-size setting.
### Text Field Suggestions
On Android, Flutter maps `enableSuggestions: false` to the visible-password input type, not only to "no suggestions"
(`TextInputPlugin.inputTypeFromTextInputType` in the engine). Input methods then treat the field as a password: no
clipboard panel, no Chinese layouts, and on some systems a secure keyboard. With the app's clipboard permission denied,
that leaves no way to paste a link at all. Only the secret fields, `SecretTextFormField` and `FormRowSecretTextField`,
ask for a password keyboard; a link, key, rule or regex field that should not be corrected sets `autocorrect: false`
alone. `test/lint/text_field_suggestions_test.dart` fails on `enableSuggestions: false` or
`TextInputType.visiblePassword` anywhere else in `lib/`.
### Length Units
Flutter lengths are logical pixels, the unit Android calls dp. Comments, test names, docs and reports give them in dp,
as in `48 dp`, never px: a reader takes px for physical pixels and misjudges the size by the device pixel ratio. Write
"physical pixel" in full where the code really works in them, as the hairline snapping in
`lib/widgets/list_row_separator.dart` and the Win32 frame metrics in `plugins/window` do; raster image sizes in
`lib/ui/list_row_separator.dart` and the Win32 frame metrics in `plugins/window` do; raster image sizes in
`tool/` and CSS under `site/` keep px. `test/lint/length_unit_test.dart` fails on a number followed by px in `lib/`
and `test/`.
@@ -102,7 +131,7 @@ judgment, and it lives in the rules below and in review. Whether comments are to
that contains them. This does not need approval.
- These are not comments and are exempt from every rule here, including the density count: analyzer and linter
directives (`// ignore:`, `// ignore_for_file:`, `// coverage:ignore`), license and copyright headers,
code-generation markers, and comments inside vendored upstream code such as `lib/widgets/open_container.dart`.
code-generation markers, and comments inside vendored upstream code such as `lib/ui/open_container.dart`.
### Density
@@ -154,7 +183,8 @@ leaving a repo-wide policy as a comment reaches only the reader of that one file
A launch mode that runs the Core as any other user has to widen `admit` in `ipc/access.rs`, and Windows
keeps its identity check on the Dart side, which compares the named-pipe peer PID with the Core it launched.
- Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, the
gomobile binding, and desktop IPC. Do not double-encode `arguments`, `result`, or event batches.
gomobile binding, and desktop IPC. Do not double-encode `arguments`, `result`, or event batches. A response whose
`file` is set was spilled to disk; a transport resolves it with `resolveSpilledResponse` before reading `result`.
- `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
@@ -260,15 +290,10 @@ leaving a repo-wide policy as a comment reaches only the reader of that one file
failure (user stop, low memory, signal, package change), never create one, and its timestamp is consumed through
`handledExitAt` so the same exit cannot be counted twice.
- `BootGuard` is an Android-only mechanism and gates itself: on every other platform `evaluate`, `markRunning` and
`markClosed` return without touching preferences. The desktop has neither of the two attribution sources — no
`ApplicationExitInfo` equivalent and no Crashlytics toggle — so a bare sentinel there would read a window closed on
the disclaimer dialog, or a session ended by shutdown, as a failed launch. Keep the platform check inside the guard;
callers in `bootstrap.dart` and `SystemAction` stay unconditional.
- `FirebaseCrashlytics.didCrashOnPreviousExecution()` corroborates a failure and must not trigger one. Its marker file is
cleared by a background initialization that `dataCollectionArbiter` blocks while collection is disabled, which is the
default here, so one real crash makes it return true on every later launch. That latch is what made the app clear the
selected profile on every cold start; it is also why the probe is only read when `crashlytics` is enabled, keeping
Firebase uninitialized until the user consents.
`markClosed` return without touching preferences. The desktop has no `ApplicationExitInfo` equivalent to attribute an
exit, so a bare sentinel there would read a window closed on the disclaimer dialog, or a session ended by shutdown,
as a failed launch. Keep the platform check inside the guard; callers in `bootstrap.dart` and `SystemAction` stay
unconditional.
- Recovery is graded: the first failed launch only skips `initStatus`, and `currentProfileId` is cleared only from
`crashRecoveryClearThreshold` consecutive failures on. Do not let a single interrupted launch write to the config.
- Desktop process ownership belongs to `DesktopCoreLifecycle`; do not start/kill `FlClashCore` from providers, widgets,
@@ -376,7 +401,31 @@ 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. A test that does claim
the singleton calls `CoreController.resetInstance()` in `tearDown`.
the singleton calls `CoreController.resetInstance()` in `tearDown`. It also only reaches the global `coreController`
when nothing has read it yet: that is a top-level `final`, bound to whichever instance existed at the first read, which
in a shared isolate is another file's.
### Shared Test Isolate
`tool/test.dart` imports many test files into one entrypoint, so process-wide state outlives the file that set it, and
every file's `main()` runs before any test of the entrypoint does. Keep `main()` to declarations and
`TestWidgetsFlutterBinding.ensureInitialized()`: a mock installed there is replaced by the next file's before a test
sees it. Set up what the tests need in `setUp` or `setUpAll` instead, from the helpers that rebind the singletons:
- `appPath` resolves its directories once per process. Point them at a test's directory with `await useAppPaths(dir)`
from `test/helpers/app_paths.dart`, not with a `PathProviderPlatform` fake, which only works while nothing has asked
`appPath` yet.
- `preferences` opens its store once per process. Use `await mockPreferences(values)` from
`test/helpers/preferences.dart` rather than `SharedPreferences.setMockInitialValues` alone, which leaves `preferences`
on the store it already opened.
Both cache the resolved value, not the future that produced it. A future completed in one test's fake-async zone runs
its listeners in that zone, so a later test that awaits it never resumes. Await the helpers outside `testWidgets`
bodies for the same reason.
A test that only passes in a fresh isolate is leaning on a global it did not set up: `database_test.dart` once caught
its write failures only because `currentAppLocalizations` threw before anything had loaded them. The script reruns such
a file alone so it cannot fail the run, and lists it; fix it where it reads the global.
Construct the Android lib handler with `CoreLib.scoped(fakeService)`. The `service` global is gated on `Platform.isAndroid`
and is therefore null on every test host, so a `CoreLib()` built from it silently takes the null-service fallback on every
@@ -460,9 +509,9 @@ A public top-level declaration that nothing outside its own file references is d
riverpod notifier is reached through its generated provider) and barrels as neither. Files publishing only extensions or
typedefs are skipped: those are reached through the types they attach to, never by name.
`CommonScaffold` adds the height of its docked search and floating action button to `BottomInsetScope` around its
body, so a page that reads `BottomInsetScope.of(context)` with the context it builds the scaffold from gets the inset
without them, and the end of its content stays under the search field or the button. Read it below the scaffold,
`CommonScaffold` adds the height of its docked search to `BottomInsetScope` around its body, so a page that reads
`BottomInsetScope.of(context)` with the context it builds the scaffold from gets the inset without it, and the end of
its content stays under the search field. Read it below the scaffold,
inside `body: Builder(builder: (context) => ...)`. `test/lint/scaffold_bottom_inset_test.dart` enforces this by
scanning `lib/`.
@@ -569,6 +618,12 @@ data move, or a test for one, check that the state it reads shipped: `git tag --
introduced it, or `git show <tag>:<path>` for the file as the release had it. A state that only ever existed between
two releases gets no migration; a development install holding it may lose that setting or fall back to its default.
A release that resets or rewrites settings, instead of reading their old shape, adds a step to `_upgrades` in
`lib/common/migration.dart` under the next `upgradeVersion`. A step takes and returns the `Config`, runs once on an
install that already had settings, and never on a new one. Leave `currentVersion` for changes to the stored format:
backups carry it, and an older app refuses a backup stamped newer than its own, so bumping it for a reset would strand
backups between devices on different releases.
The database takes one migration step per release. The newest step in `lib/database/database.dart` has not shipped
while `schemaVersion` at the last release tag is below its number; a schema change then goes into that step, raising
`schemaVersion` and the step's condition together so a development install runs the step again, instead of adding a
@@ -583,6 +638,7 @@ Do not manually edit generated files under:
- `lib/models/generated/`
- `lib/providers/generated/`
- `lib/database/generated/`
- `lib/ui/generated/`
- `lib/l10n/intl/`
After schema, model, or provider changes, run build generation and include focused tests when behavior changes.
@@ -596,7 +652,7 @@ l10n paths for that reason. It is harmless because the file only needs `Locale`,
human writes takes Material from `material_ui`; `cupertino_ui` is banned outright and
survives only as a transitive dependency of `material_ui`.
Strings live in `arb/intl_{en,zh_CN,ja,ru}.arb` — flat JSON, no `@` metadata. Add a key to all four, then regenerate with
Strings live in `arb/intl_{en,zh_CN,ja,ru,fr,es,ko}.arb` — flat JSON, no `@` metadata. Add a key to all seven, then regenerate with
`dart run intl_utils:generate`, which rewrites `lib/l10n/`. A key present in only some locales silently falls back to
English at runtime, so add the translation rather than leaving it out.
+18 -12
View File
@@ -23,9 +23,9 @@ The update path carries these fields:
- From `PatchClashConfig`: `mode`, `log-level`, `mixed-port`, `allow-lan`, `ipv6`, `find-process-mode`,
`tcp-concurrent`, `unified-delay`, `external-controller`, `secret`, `geo-auto-update`, `geo-update-interval`,
`geox-url`, and every `tun` field.
- From other settings: `NetworkProps.authentication` and `bypassPrivateRoute`, which `Tun.getRealTun` folds into the
TUN exclusions, and `AppSettingProps.checkCertificate`, sent as `skip-cert-verify`.
`geox-url`, and the `tun` keys of `Tun.coreJson`: `enable`, `device`, `stack`, and `auto-route`, always on.
- From other settings: `NetworkProps.authentication`, and `AppSettingProps.checkCertificate`, sent as
`skip-cert-verify`.
On desktop, `updateListeners` rebuilds the TUN from the patched `tun`. Enabling TUN may need authorization first. When
`requestAdmin` gets it newly granted, `updateConfig` restarts the Core instead of patching it. On Android,
@@ -45,15 +45,17 @@ Prefer update whenever Go has a setter for the setting, because an apply reloads
## Apply
`CoreManager` answers each change of `setupPatchProvider` with `applyProfileDebounce(silence: true)`. That call
rebuilds the profile YAML and hands it to Go's `applyConfig` through `setupConfig`. The provider holds two values:
rebuilds the profile YAML and hands it to Go's `applyConfig` through `setupConfig`. The provider holds three values:
- `PatchClashConfig.setupOnly`: the config with every field that update carries reset to its default.
- `NetworkProps.appendSystemDns`.
- `NetworkProps.bypassPrivateRoute` on desktop, where the task adds the private ranges to the TUN's final
`route-exclude-address`.
A change to anything else in `PatchClashConfig` therefore reaches it. Today that is the DNS override (`dns`,
`dnsOverrideKeys`), `hosts`, the extra inbound ports (`port`, `socks-port`, `redir-port`, `tproxy-port`), the dialer
options (`keep-alive-*`, `disable-keep-alive`, `interface-name`, its mode, `routing-mark`), `geodata-loader` and
`global-ua`. A new `PatchClashConfig` field written by `_makeRealProfileTask` lands here without further work. A value
A change to anything else in `PatchClashConfig` therefore reaches it. Today that is the DNS and TUN overrides (`dns`,
`dnsOverrideKeys`, `tunOverride`, `tunOverrideKeys`), `hosts`, the extra inbound ports (`port`, `socks-port`,
`redir-port`, `tproxy-port`), the dialer options (`keep-alive-*`, `disable-keep-alive`, `interface-name`, its mode,
`routing-mark`), `geodata-loader` and `global-ua`. A new `PatchClashConfig` field written by `_makeRealProfileTask` lands here without further work. A value
the profile build reads from another settings object has to be added to `setupPatchProvider`.
An apply without `force` sends nothing when the md5 of the generated YAML has not changed. A value that only
@@ -64,9 +66,10 @@ Profile content does not go through `setupPatchProvider`. `setupStateProvider` r
database once instead of watching them. Content is applied explicitly by whatever changes it:
- Profile editors call `autoApplyProfile` when they close.
- Subscription updates, `setProfileAndAutoApply`, scripts and app-level providers call `applyProfileDebounce`.
- Subscription updates, scripts and app-level providers call `applyProfileDebounce`.
- A profile switch runs `fullSetup`.
- A start applies again with `force`.
- A start applies again with `force` unless the last apply saw the same inputs (`_inputsApplied`), the `testUrl` and
`skipCertVerify` that only `SetupParams` carries included.
## Restart
@@ -116,8 +119,11 @@ options, because the service does not report the options it was started with.
Each path watches its own provider, so a single setting can take more than one of them:
- `mixedPort`: update moves the Core's listener; under the VPN system proxy, the restart notice follows.
- TUN fields and `bypassPrivateRoute`: update rebuilds the desktop TUN. On Android the update reaches no TUN, and the
restart notice carries the change.
- TUN switch and stack: update rebuilds the desktop TUN. On Android the update reaches no TUN, and the restart notice
carries the stack.
- MTU, congestion control and routes: Android's rows take the restart path; on desktop they are TUN overrides, applied
with the profile.
- `bypassPrivateRoute`: apply on desktop, restart on Android.
- Local authentication: update applies it to the inbounds. It also switches the VPN system proxy, which needs a
restart.
+3
View File
@@ -25,6 +25,9 @@ Do not use this for README translation sync or manual edits to generated localiz
- `arb/intl_zh_CN.arb`
- `arb/intl_ja.arb`
- `arb/intl_ru.arb`
- `arb/intl_fr.arb`
- `arb/intl_es.arb`
- `arb/intl_ko.arb`
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`.
+40 -21
View File
@@ -19,27 +19,41 @@ dialogs, and interaction behavior.
- `.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).
- A component that reads no provider, model or app string goes in `lib/ui/`; one built for an app feature goes in
`lib/widgets/`. The UI Layer section of `.agents/architecture.md` says how a `lib/ui` component gets app state.
- 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
- Every icon lives in `lib/ui/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.
- Text sizes and weights come from `AppTextStyle` in `lib/ui/typography.dart`, Apple's iOS Dynamic Type scale, never a
Material `TextTheme` slot, a bare `fontSize`, or a `fontWeight`; the Typography section of `.agents/rules.md` has the
rule.
- Modals dim what they cover with the Material 3 scrim (`ColorScheme.modalScrim`, `scrim` at 32%) and never blur it.
Every modal brings its own scrim, including one opened from inside another, so do not make a barrier transparent to
keep scrims from stacking.
- A sheet's size belongs to its route, never to its content or call site: a bottom sheet rests at
`SheetProps.detents`, and a side sheet is as tall as the window and as wide as `ModalSideSheetRoute` makes it from
the window width (`lib/widgets/side_sheet.dart`, pinned by `test/widgets/side_sheet_width_test.dart`). Do not wrap a
sheet's content in a fixed width or height to resize one sheet.
`SheetProps.detents`, a side sheet is as tall as the window and as wide as `ModalSideSheetRoute` makes it from
the window width (`lib/ui/side_sheet.dart`, pinned by `test/widgets/side_sheet_width_test.dart`), and a form sheet
is a card `FormSheetRoute` caps in width and height (`lib/ui/form_sheet.dart`). Do not wrap a sheet's content in a
fixed width or height to resize one sheet.
- A form with more than one value opens with `showFormSheet` and lays its values out in `FormRow`s, never as labeled
fields in a `CommonDialog`; a prompt for one value stays an alert. Forms in `.agents/architecture.md` has the rule.
- A modal scrolls only once the window and keyboard leave it no room, and then through one scroll view: `CommonDialog`
caps its body at nothing but the space it is given, and a multi-line field in a dialog or sheet grows with its text
(`maxLines: null`) rather than scrolling inside a line count. With the keyboard up, a bottom sheet keeps what it
shows above the keyboard, rising past its tallest detent as far as a peek below the top (`_top` in
`lib/ui/snap_sheet.dart`).
- The keyboard animates `MediaQuery.viewInsets` every frame. Read MediaQuery by aspect (`paddingOf`, `viewInsetsOf`)
rather than `MediaQuery.of`, and wrap the page of a new route type in `KeyboardInsetHold`; Keyboard Insets in
`.agents/architecture.md` has the reasons.
- A page full of images loads them once its route has settled and builds no rows past the viewport until then:
`RouteSettledMixin` and `arrivalScrollCacheExtent`, described under Page Arrival in `.agents/architecture.md`.
- A page that waits on its data as it arrives shows its illustration alone (`NullStatus` without a label, in a
`NullStatusSwitcher`) until all of it is ready, never a skeleton screen; Page Arrival in `.agents/architecture.md`
has the rule.
- A page loads what it shows as it mounts, never once its route has settled; what waits out a transition is work
that repeats, such as a live feed's refreshes (`RouteMotionHoldMixin`), and the rows past the viewport
(`arrivalScrollCacheExtent`). Page Arrival in `.agents/architecture.md` has the rule.
- A page that waits on its data shows nothing until all of it is ready (`NullStatusSwitcher.isLoading`), and a load
that can run long adds its illustration as `loadingStatus`, never a skeleton screen; Page Arrival in
`.agents/architecture.md` has the rule.
- App bar buttons reach `CommonScaffold` as data (`primaryAction`, `iconActions`, `menuItems`) so it can fold them into
its overflow menu; App Bar Actions in `.agents/architecture.md` has the rule.
- A row's secondary actions open from a context menu, never a trailing more button; a list whose rows have one
@@ -57,7 +71,7 @@ dialogs, and interaction behavior.
## Corner Radii
All corner radii come from `lib/common/shape.dart`: a token, a derived value, or a named off-scale value passed to its
All corner radii come from `lib/ui/shape.dart`: a token, a derived value, or a named off-scale value passed to its
builders. Never write a bare radius literal at a call site.
The scale is picked by the component's **shortest side**. A radius that reads as a soft card at 64 dp tall
@@ -68,10 +82,10 @@ reads as a pill at 24 and as a square at 400, so one radius everywhere is the wr
| `none` | 0 | - | square edges, and the flat side of a grouped run |
| `xs` | 4 | inset blocks | tiles clipped inside an already rounded surface |
| `sm` | 8 | up to 48 | chips, thumbs, swatches, small square tiles |
| `md` | 16 | up to 64 | interactive chrome: inputs, menus, buttons, popups, FAB |
| `md` | 16 | up to 64 | interactive chrome: inputs, menus, buttons, popups |
| `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 |
| `xxl` | 28 | over 200 | sheets, full-screen containers |
| `full` | 1000 | - | pills and circles: buttons, tracks, indicators, progress, avatars |
- `AppCorner` holds the scale as `double`; `AppRadius` mirrors it as `BorderRadius` with `all`, `top`, and `vertical`
@@ -80,19 +94,21 @@ reads as a pill at 24 and as a square at 400, so one radius everywhere is the wr
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
- `ThemeData.withAppShapes` in `lib/ui/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`.
- Colors of surfaces follow `.agents/surfaces.md`: fills by `SurfaceLevel`, no structural borders, and
`primaryContainer` only on a selected item.
`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
group card 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.
same radius. `lg` is for mid-size cards that are not full-bleed: a proxy node card in the tab view at
`ProxyCardType.compact` 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 both heights and the smallest dashboard tile.
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
@@ -100,12 +116,15 @@ up. `AppInputBorder` clips the notch region and paints the full superellipse thr
`OutlineInputBorder` does by shortening the arc. It needs no `contentPadding` compensation.
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.
the outer value, as `_cardRadius` in `lib/ui/popup.dart` does. Adding a token for one of these is how a scale grows without
bound. A card that also stands on its own elsewhere keeps its token when nested: the proxy node cards inside a proxies
list group stay `lg` like those in the tab view, since deriving them from the group card's `xl` left them at 14, smaller
than every other card in the app.
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.
it to the `all`/`top`/`vertical` builders, as `_itemShape` in `lib/ui/sidebar.dart` does with 10, between the
chip-sized `sm` and the near-pill `md` on a 40 tall row, and `_dialogCorner` in `lib/ui/shape.dart` does with 34, the
corner of an iOS alert. Reach for this only once the table and `fit()` are ruled out.
## Verification
+187
View File
@@ -0,0 +1,187 @@
# Surfaces
FlClash draws structure with fills and keeps color for state. A surface stands out from what it sits on by a step in
lightness, never by a border. Color appears where something is selected, on, failing, or the one action a screen is
for. The dashboard set this direction; every other screen follows it, and Material components keep their behavior
but give up their tonal container fills.
## Levels
`SurfaceLevel` in `lib/ui/surface.dart` names four depths. Each surface takes the level one deeper than the one
it sits on, which `SurfaceScope` passes down the tree.
| Level | Light | Dark | Holds |
|---------|---------------------------|---------------------------|---------------------------------------------------------|
| `page` | `surfaceContainer` | `surface` | scaffold body, sheets |
| `card` | `surfaceBright` | `surfaceContainer` | cards, grouped runs of rows, light-mode floats |
| `block` | `surfaceContainer` | `surfaceContainerHigh` | unselected segments, tiles and wells in a card |
| `inset` | `surfaceContainerHighest` | `surfaceContainerHighest` | small marks inside a block: glyph wells, badges, tracks |
Light mode alternates gray and paper: a near-white card on a gray page, a gray block in the card. The card is tone 98,
not pure white, and the page tone 94: pure white beside a tinted gray made the white glare and the gray look soiled. Dark mode only
lightens, because a darker step inside a lighter surface reads as a hole rather than a layer. `inset` is the floor;
nothing nests below it, so a design that needs a fifth level needs fewer containers instead.
- A widget never picks its level at the call site. `CommonCard` and the other shared surfaces read
`context.raisedSurfaceLevel` and wrap their child in `SurfaceScope`, so the same row is white on a page and gray
inside a card. A branch on `isInBottomSheet` or on where a widget is placed to choose its fill is the bug this
replaces.
- Sheets are `page` level, like a page, so a section looks the same in a sheet and in a page. In dark mode a sheet
presents a level up, as iOS presents a sheet on its elevated backgrounds: `ColorScheme.elevated` lifts every
surface role by the step from `page` to `card`, so the sheet's page takes the tone of the cards it covers and every
level inside it follows. Under pure black the sheet takes the plain dark scheme instead, which is the same step up
and keeps the seed's tint that a lift from black would lose. `ElevatedColorScheme` carries the result and the sheet
routes apply it through `ElevatedSurfaceTheme`; a sheet opened from a sheet keeps the same lift. A scrim barely
darkens a dark page, so a dark sheet at the page's own tone read as more of the page rather than a layer over it,
a covered sheet's peek vanished, and on black the edge and corners went with it. Light mode keeps its levels,
because the scrim already sets a light sheet apart.
- What floats over content, dialogs, menus, popups, the mobile dock, toasts, takes `SurfaceLevel.floatingOf`: `card`
in light, `block` in dark. A shadow does not show on a dark page, so a float has to come out lighter than the cards
it covers, and its content scopes to that level so a block inside still steps away from it.
- Shadows mark what floats, plus the dashboard cards. Cards in a scrolling list have none; a grid of proxies with a
blurred shadow each costs raster time for no extra layering. A render object that paints a shadow itself draws it
with `drawRSuperellipse`, as `ShapeDecoration` does: Impeller blurs a superellipse in one analytic pass, but a path
through an offscreen Gaussian blur on every frame, which made the shadows the largest raster cost of an opening menu.
## The Seeded Scheme
`seededColorScheme` in `lib/ui/color.dart` builds every app scheme: Material's scheme for the seed and the
`ColorStyle` picked in the theme settings, with three groups of roles replaced. Everything else stays Material,
including `toPureBlack` applied after it.
The four styles each change something a reader sees at once. Material's nine variants mostly moved secondary and
tertiary, which the app barely shows, so most of them looked alike once surfaces stayed gray.
| Style | Material variant | Primary | Surfaces |
|--------------|------------------|-------------------|---------------------------------|
| `vivid` | content | the seed | near gray |
| `tinted` | content | the seed | the seed's hue, up to chroma 10 |
| `soft` | tonal spot | Material's, muted | near gray |
| `monochrome` | monochrome | black or white | gray |
- **Surfaces stay near gray.** Each `surface*` role keeps Material's tone but its chroma is capped at 3, the light
`surface` and `surfaceBright` at 1.5, and `outlineVariant` at 4. Material tints neutrals by the seed (content
divides the seed's chroma by 8), so a yellow, green, or rose seed tinted the whole page. `tinted` is that wash
chosen on purpose and made even: every surface takes the seed's hue at a sixth of its chroma, capped at 10, so a
gray seed stays gray and any vivid one tints about as much.
- **`primaryContainer` comes from the primary palette.** It is primary's hue at tone 90 in light and 30 in dark,
with `onPrimaryContainer` at 30 and 90, and the pair's chroma is capped at 48. Content and fidelity otherwise put it
at the seed's own tone: a yellow seed filled it with pure yellow, a red seed with something that read as an error,
and dark mode with a light block under light text. The cap matters for light, chromatic hues: yellow at tone 90 is
still close to full strength.
- **`vivid` and `tinted` keep the seed as `primary`**, as an Apple accent color does: the color picked in the theme
settings is the color of every check, switch, and filled button. Light holds the seed's tone between 35 and 60, so
white text on it keeps about 3:1 and a near-black seed still reads as a color; dark lifts it 3 tones, as iOS lifts
its system colors, within 50 to 75, and puts dark text on it above 60. Material sets primary at tone 40 and 80 for
any seed, which turned iOS blue into navy in light and a pastel in dark. `soft` and `monochrome` keep Material's
primary, since moving away from the seed is what they are for.
## State
Color marks the one item that differs, never the structure around it.
- **Selected**, one of several rows, cards, or tiles: the item keeps its level's fill and a primary mark says which,
as iOS checks the option picked from a list. A row in a grouped run takes `SelectionCheck` at its edge; a card that
stands on its own, a profile or a proxy, and a tile take the filled `SelectionBadge`, which holds its own beside a
40 dp icon well where the bare check read as small and thin (`lib/ui/selection_mark.dart`). `CommonCard.isSelected` only reports the state to
accessibility. Fills were tried and rejected: a wash of primary landed on a gray that did not read as a choice, and
`primaryContainer` read as a warning under rose seeds in light and as a heavy slab in dark, muddied the translucent
marks on it, and cut a grouped run in two.
- **Multi-select** in edit mode: the circle checkbox is the mark; the row stays at its level.
- **Proxy groups** have two states in URLTest and Fallback: the check badge marks the proxy traffic goes through, in
every group type, and a pin badge (`SelectionBadge.preferred`) marks the preferred proxy (mihomo's `fixed`): filled
like the check while the group uses it, tonal while the group has fallen back to another proxy, which carries the
check.
- **Chosen segment** in a connected button group, a mode switch, or a run of compact choice chips
(`CommonCard.fillsSelection`): solid `primary` with `onPrimary`, as the dashboard outbound mode does. Only one
segment per group.
- **Error** on a row or card: `errorContainer` with `onErrorContainer`, no border.
- **Status** values (delay, availability, success, warning): the color goes on text, a glyph, or a small chip, never
on a whole card. `success` and `warning` come from fixed green and orange palettes that ignore the seed, like
Material's `error`; harmonizing pulled orange toward red under rose seeds until warning and error matched. Text takes
tone 40 in light and 80 in dark, a chip takes `successContainer`/`warningContainer` (90/30) with the `on` role
(30/90). A delay under 600 ms is success, above it warning, a failure error: `ColorScheme.delayColor`.
- **Destructive** action in an alert: `ColorScheme.destructive`, iOS 27's systemRed in both modes, not `error`. A
menu's `danger` item still takes `error`.
- **Data series** on one chart: the first is primary, the second primary at 45% over the card
(`DashboardColors.series2`), so they differ by lightness under any seed. Tertiary is not a series color.
- **Text**: titles and values `onSurface`, labels and secondary lines `onSurfaceVariant`. `outline` is not a text
color, and a value is never tinted primary.
- **Marks** that only identify, a country badge, a group or service icon, a source avatar, sit on a neutral well with
`onSurfaceVariant`.
- `primaryContainer`, `secondaryContainer`, and `tertiaryContainer` are neither surface nor selection fills. Keep
them out of cards, rows, marks, and navigation indicators.
## Borders That Stay
- **Focus**: a 2 dp primary stroke while a surface has keyboard or remote focus. TV navigation depends on it, and a
fill change alone is too faint to follow from a couch. A card, a grouped row above all, rings only focus a key moved
there (`FocusMovedByKey`), or any focus while a remote drives the app: a tap or a click puts focus where the reader
is already looking, a field's caret already marks it, and the ring then read as a selection box around the row.
- **Inputs**: a form's field is a bare `FormRowTextField` at the end of a grouped `FormRow` whose title labels it, as
iOS and macOS set one, with no stroke or fill of its own. A field among filled controls takes their fill, one level
above its surface, through `FilledInputTheme`. An alert's fields, because they sit over its pill buttons, as
UIAlertController sets them: as wide as the buttons, in their fill, with their text in line with the title and the
label as the placeholder (`CommonDialog.hasFields`); a field under a filled block of text in an alert takes the
block's width and corner instead. The custom color's hex field, because its card holds only fills, the color
previews, where an outline was the one stroked thing. Search fields are filled pills. A sheet's docked search,
because the list scrolls under it: it floats like the dock, with the floating fill and the dock's shadow, or the
dock's glass. The editor's find bar, because its fields sit in a row of filled pill buttons: they take the buttons'
fill, one level above the bar. All of these stroke only while focused. Any other field, one standing alone on a page,
is outlined, with the floating label notched into the stroke: `ThemeData.withAppShapes` gives every input
`AppShape.input`, so call sites leave `border` unset. A lone field is never filled: here a fill means structure, so
one read as one more card or row, and in light mode the hover overlay brought it down to the page's own tone. No
field counts its characters, as none does on iOS or macOS; a length limit holds through its formatter. Material
strokes a filled field's whole outline in `onSurfaceVariant`, which ringed the pill brighter than anything else in a
dark sheet.
- **Window chrome**: the desktop sidebar's inner edge and the title bar's bottom edge draw a divider where they meet
the content. The chrome fill alone left the edge to a four-tone step, which read as a smudge rather than a boundary.
Nothing else draws an outline. Rows in a grouped run are separated by the hairline from `lib/ui/list_row_separator.dart`, which divides
one surface and does not outline it. Record lists, requests, connections, logs, and DNS queries, are not grouped: they
run to thousands of tall rows, and one card that long reads as a slab rather than a group. A stacked row of three or
four lines is already its own block, so each is a card of its own, `RecordListItem`: the raised fill at `xl`, 16 dp
from the edges and 8 dp from the next, as the profile cards sit. Every card takes the same fill, so the warning and
error tints on a label still stand out, where a fill on every other row competed with them; rows on the page split by
a full-width divider were the one structural line left on screens otherwise drawn with fills. Laid out as a table,
where a row is one line read across many columns, they drop the gap and give every other row the raised fill, inset
and rounded like the column header, so the eye keeps its row; the dashboard's table does the same.
Every divider takes its color from `ThemeData.withAppSurfaces`: `onSurface` at 14%, never `outlineVariant`. A
translucent line keeps the same contrast on the page, a card, or the chrome, in both modes; `outlineVariant` stood out
twice as much on the dark page as on the light one and carried the seed's tint.
## Navigation
- The desktop sidebar and title bar are window chrome, `ColorScheme.chromeSurface`: `surfaceContainerHighest` in
light and `surfaceContainerLow` in dark, four tones from the page either way, with a divider along the edge that
meets the content. At `page` level the sidebar merged with the content. In dark the chrome stays below `card`, so
cards still stand out from it. The pane the sidebar opens over the content takes the same fill and edge, plus a
shadow.
The selected destination keeps its neutral fill and primary bar, which already marks it with color.
- The mobile dock and the docked navigation bar are floats; their indicator is a neutral platter one level above
them, and the selected destination's content takes primary, as iOS's tab bar marks its selection.
- With Liquid Glass on (Theme settings), the dock, the buttons of a page's bar, a sheet's docked search, popup
menus, and alerts are glass from `lib/ui/liquid_glass.dart` instead of a fill, and the dock's platter takes the
secondary hue and chroma at tone 90, or at tone 4 in dark, which sinks it below the bar as iOS 27 does.
- The proxies page's group tabs are compact choice chips, the current group in the chosen segment's solid `primary`.
Once they overflow, a tonal button at their end opens every group as a popup menu, the current one checked and
scrolled into view. A profile can carry dozens of groups, so the menu stops at seven rows and half of the next.
## Where It Lives
- `lib/ui/surface.dart`: `SurfaceLevel`, `SurfaceScope`, `context.surfaceLevel`, `context.raisedSurfaceLevel`,
`ColorScheme.chromeSurface`, and `ThemeData.withAppSurfaces`, applied next to `withAppShapes` wherever the app
builds its theme.
- `lib/ui/color.dart`: `seededColorScheme`, the status palettes, and `delayColor`.
- `lib/views/dashboard/style.dart`: `DashboardColors` maps `page`, `card`, `tone`, and `tone2` onto the levels and
adds the dashboard's switch, series, and status colors.
## Checking A Change
Render the touched screens in light and dark, on a phone width and a desktop width, before and after. A throwaway
widget test does it: pump `HomePage` or the view inside a `RepaintBoundary`, load Roboto with `FontLoader` so text
gets real glyphs, and write `RenderRepaintBoundary.toImage` to a PNG outside the repository. Look for a surface that
matches the one it sits on, which means it skipped `SurfaceScope`, and for a tonal container fill that survived on a
card or row.
+39 -20
View File
@@ -14,6 +14,8 @@ concurrency:
env:
IS_STABLE: ${{ !contains(github.ref, '-') }}
# See CI Caches in .agents/commands.md.
BRANCH_CACHE: ${{ !startsWith(github.ref, 'refs/tags/') }}
FLUTTER_VERSION: '3.47.6'
GO_VERSION: '1.27.1'
NDK_VERSION: r28c
@@ -40,13 +42,14 @@ jobs:
with:
workspaces: plugins/rust_api/rust
cache-directories: .dart_tool/hooks_runner
save-if: ${{ env.BRANCH_CACHE }}
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
cache: ${{ env.BRANCH_CACHE }}
- name: Install dependencies
run: flutter pub get
@@ -66,7 +69,7 @@ jobs:
run: dart run tool/changelog.dart verify
- name: Run tests
run: flutter test --reporter expanded --coverage
run: dart run tool/test.dart -- --reporter expanded --coverage
- name: Check coverage
run: dart run tool/check_coverage.dart coverage/lcov.info 75
@@ -84,7 +87,7 @@ jobs:
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
cache: ${{ env.BRANCH_CACHE }}
- name: Validate local Flutter packages
run: bash tool/check_plugins.sh
@@ -98,7 +101,7 @@ jobs:
desktop:
name: Desktop ${{ matrix.platform }}
if: *checks
if: (github.event_name == 'workflow_dispatch' || !github.event.repository.private) && !(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -120,7 +123,9 @@ jobs:
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: core/go.sum
cache-dependency-path: |
core/go.sum
plugins/setup/setup_hooks/lib/src/go_builder.dart
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
@@ -176,6 +181,7 @@ jobs:
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: ${{ env.BRANCH_CACHE }}
cache-dependency-path: core/go.sum
- name: Validate Go core wrapper
@@ -218,14 +224,17 @@ jobs:
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: core/go.sum
cache: ${{ env.BRANCH_CACHE }}
cache-dependency-path: |
core/go.sum
android/core/build.gradle.kts
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
cache: ${{ env.BRANCH_CACHE }}
- name: Install dependencies
run: flutter pub get
@@ -245,6 +254,8 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
- name: Run Android unit tests
working-directory: android
@@ -279,6 +290,7 @@ jobs:
workspaces: |
services/helper
plugins/rust_api/rust
save-if: ${{ env.BRANCH_CACHE }}
- name: Check format
run: |
@@ -302,6 +314,7 @@ jobs:
uses: Swatinem/rust-cache@v2
with:
workspaces: services/helper
save-if: ${{ env.BRANCH_CACHE }}
- name: Check format
run: cargo fmt --manifest-path services/helper/Cargo.toml -- --check
@@ -312,7 +325,6 @@ jobs:
build:
# 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:
matrix:
@@ -334,9 +346,11 @@ jobs:
- platform: windows
os: windows-11-arm
arch: arm64
flutter-channel: master
- platform: linux
os: ubuntu-24.04-arm
arch: arm64
flutter-channel: master
steps:
- name: Setup rust
@@ -357,7 +371,6 @@ jobs:
if: startsWith(matrix.platform,'android')
run: |
echo "${{ secrets.KEYSTORE }}" | base64 --decode > android/app/keystore.jks
echo "${{ secrets.SERVICE_JSON }}" | base64 --decode > android/app/google-services.json
{
echo "keyAlias=${{ secrets.KEY_ALIAS }}"
echo "storePassword=${{ secrets.STORE_PASSWORD }}"
@@ -368,15 +381,9 @@ jobs:
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: ${{ env.BRANCH_CACHE }}
cache-dependency-path: core/go.sum
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.platform }}-${{ matrix.arch || 'all' }}
workspaces: services/helper
cache-directories: .dart_tool/hooks_runner
- name: Setup Gradle
if: startsWith(matrix.platform,'android')
uses: gradle/actions/setup-gradle@v6
@@ -390,9 +397,9 @@ jobs:
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: master
channel: ${{ matrix.flutter-channel || 'stable' }}
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
cache: ${{ env.BRANCH_CACHE }}
- name: Setup Xcode
if: startsWith(matrix.platform,'macos')
@@ -419,6 +426,18 @@ jobs:
if: matrix.platform == 'macos' && matrix.arch == 'amd64'
run: plutil -replace FLTEnableImpeller -bool NO macos/Runner/Info.plist
# The core embeds this bundle and FlClashHttpOverrides.trustCoreRoots loads it for Dart.
# Pinned by hash, so a new bundle from https://curl.se/docs/caextract.html is a reviewed bump.
- name: Fetch CA certificates
shell: bash
env:
CA_BUNDLE: cacert-2026-09-25.pem
CA_BUNDLE_SHA256: a41b5d356aea97a529fe27e0f7316d2f9d946d75927476cf9cf1b90637d00505
run: |
bundle=core/Clash.Meta/component/ca/ca-certificates.crt
curl -fsSL --retry 3 -o "$bundle" "https://curl.se/ca/$CA_BUNDLE"
echo "$CA_BUNDLE_SHA256 $bundle" | sha256sum -c -
- name: Setup
env:
ANDROID_NDK_HOME: ${{ steps.build-ndk.outputs.ndk-path }}
@@ -434,7 +453,7 @@ jobs:
upload:
permissions:
contents: write
needs: [ build ]
needs: [ build, dart, plugins, go, android, rust, windows-helper-test ]
runs-on: ubuntu-latest
services:
telegram-bot-api:
@@ -462,7 +481,7 @@ jobs:
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
cache: ${{ env.BRANCH_CACHE }}
- name: Install dependencies
run: flutter pub get
+3 -1
View File
@@ -17,6 +17,8 @@ Read these only when the task touches their area:
local plugins.
- [.agents/setting-changes.md](.agents/setting-changes.md): whether a changed setting is patched live, re-applies the
profile, or asks for an Android VPN restart.
- [.agents/surfaces.md](.agents/surfaces.md): fill levels, selection and status colors, and the few borders that
remain, for any change to how a surface is colored.
- [.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.
@@ -55,7 +57,7 @@ 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`,
- Round every corner with a superellipse, pills included: `AppShape`/`AppRadius` tokens from `lib/ui/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).
+22
View File
@@ -1,5 +1,27 @@
# Changelog
## v0.9.0 (2026-10-14)
**Breaking Changes**
- **views** DNS and NTP override toggles are removed; picked keys apply to every profile (491fae1)
- **database** Custom overwrites are removed; rebuild them as custom profiles (1677024)
**Features**
- **views** Per-key DNS, NTP, sniffer and TUN settings (491fae1)
- **profiles** Custom profiles (3abda78)
- **ui** Stacked sheets, context menus and a sort mode (8c43053)
- **providers** DNS, Hosts and ports apply without a restart (532ebf0)
- **android** Launcher shortcuts and one-step on-demand access (d3fa225)
- **core** Share link import and mrs rule sets (2fa2f7f)
**Bug Fixes**
- **database** Unreadable settings no longer block startup or restores (1677024)
- **desktop** Windows and Linux desktop fixes (94a25f4)
- **core,helper** The elevated Core only answers its owner (d780cb8)
## v0.8.99 (2026-10-03)
**Features**
-17
View File
@@ -1,12 +1,9 @@
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
plugins {
id("com.android.application")
id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
}
val localProperties = Properties().apply {
@@ -80,10 +77,6 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
configure<CrashlyticsExtension> {
nativeSymbolUploadEnabled = hasReleaseSigning
}
}
}
@@ -103,13 +96,6 @@ 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"))
@@ -119,9 +105,6 @@ dependencies {
implementation(libs.smali.dexlib2) {
exclude(group = "com.google.guava", module = "guava")
}
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.crashlytics.ndk)
implementation(libs.firebase.analytics)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
-65
View File
@@ -1,65 +0,0 @@
{
"project_info": {
"project_number": "000000000000",
"project_id": "dev"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
"android_client_info": {
"package_name": "com.follow.clash"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
"android_client_info": {
"package_name": "com.follow.clash.debug"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
"android_client_info": {
"package_name": "com.follow.clash.dev"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
]
}
@@ -64,7 +64,7 @@ object ServiceController {
}
}
fun setEventListener(callback: ((String?) -> Unit)?): Result<Unit> = runCatching {
fun setEventListener(callback: ((ByteArray) -> Unit)?): Result<Unit> = runCatching {
Core.updateEventListener(callback)
}
@@ -35,8 +35,6 @@ internal interface ServiceStateHost {
fun showToast(message: String)
fun setCrashlytics(enabled: Boolean)
fun updateNotificationParams(params: NotificationParams)
fun loadSharedState(): SharedState
@@ -102,8 +100,6 @@ internal object AndroidServiceStateHost : ServiceStateHost {
override fun showToast(message: String) = GlobalState.application.showToast(message)
override fun setCrashlytics(enabled: Boolean) = GlobalState.setCrashlytics(enabled)
override fun updateNotificationParams(params: NotificationParams) =
ServiceConfig.updateNotificationParams(params)
@@ -182,7 +182,6 @@ internal class ServiceStateMachine(private val host: ServiceStateHost) {
}
private fun applySharedState() {
host.setCrashlytics(sharedState.crashlytics)
host.updateNotificationParams(notificationParams(sharedState))
}
@@ -8,7 +8,6 @@ data class SharedState(
val stopTip: String = "Stopping VPN...",
val localNetworkTip: String =
"Local network permission denied: using the gvisor stack, LAN is unreachable.",
val crashlytics: Boolean = true,
val currentProfileName: String = "FlClash",
val stopText: String = "Stop",
val onlyStatisticsProxy: Boolean = false,
@@ -20,6 +20,9 @@ import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.follow.clash.R
import com.follow.clash.common.Components
import com.follow.clash.common.GlobalState
@@ -103,6 +106,22 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
private var skipNotificationPermissionRequest = false
@Suppress("DEPRECATION")
private val isTelevision by lazy {
val packageManager = GlobalState.application.packageManager
packageManager.hasSystemFeature(PackageManager.FEATURE_TELEVISION) ||
packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
}
private var floatingIme = false
// A TV keyboard floats over the app and reports a zero-height IME inset.
private val imeInsetsListener = OnApplyWindowInsetsListener { view, insets ->
val ime = WindowInsetsCompat.Type.ime()
reportFloatingIme(insets.isVisible(ime) && insets.getInsets(ime).bottom == 0)
ViewCompat.onApplyWindowInsets(view, insets)
}
private val mainHandler = Handler(Looper.getMainLooper())
/**
@@ -123,8 +142,7 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
}
override fun onMethodCall(call: MethodCall, rawResult: Result) {
val result = MainThreadResult(rawResult)
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"moveTaskToBack" -> {
activity?.moveTaskToBack(true)
@@ -181,10 +199,6 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
result.success(openAppSettings())
}
"didCrashOnPreviousExecution" -> reply(result) {
GlobalState.didCrashOnPreviousExecution()
}
"getLastExitInfo" -> reply(result) {
GlobalState.lastExitInfo()
}
@@ -432,9 +446,21 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
activity = binding.activity
binding.addActivityResultListener(activityResultListener)
binding.addRequestPermissionsResultListener(permissionsResultListener)
if (isTelevision) {
ViewCompat.setOnApplyWindowInsetsListener(
binding.activity.window.decorView,
imeInsetsListener,
)
}
}
private fun detachFromActivity() {
if (isTelevision) {
activity?.window?.decorView?.let {
ViewCompat.setOnApplyWindowInsetsListener(it, null)
}
reportFloatingIme(false)
}
activity = null
val binding = activityBinding ?: return
activityBinding = null
@@ -442,6 +468,14 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
binding.removeRequestPermissionsResultListener(permissionsResultListener)
}
private fun reportFloatingIme(floating: Boolean) {
if (floating == floatingIme) {
return
}
floatingIme = floating
channel.invokeMethod("floatingImeChanged", floating)
}
override fun onDetachedFromActivityForConfigChanges() {
detachFromActivity()
}
@@ -1,37 +0,0 @@
package com.follow.clash.plugins
import android.os.Handler
import android.os.Looper
import io.flutter.plugin.common.MethodChannel
/**
* Delivers a [MethodChannel.Result] on the main thread, which is where Flutter
* requires it, from handlers that finish on a background dispatcher.
*
* The hop goes through the main looper rather than the plugin's coroutine scope:
* that scope is cancelled when the engine detaches, and a reply dropped by a
* cancelled scope leaves the Dart future waiting forever. A reply posted after
* the engine is gone is the messenger's problem to ignore, and it does.
*/
internal class MainThreadResult(
private val delegate: MethodChannel.Result,
) : MethodChannel.Result {
override fun success(result: Any?) = post { delegate.success(result) }
override fun error(code: String, message: String?, details: Any?) =
post { delegate.error(code, message, details) }
override fun notImplemented() = post { delegate.notImplemented() }
private fun post(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
block()
} else {
mainHandler.post(block)
}
}
private companion object {
val mainHandler = Handler(Looper.getMainLooper())
}
}
@@ -6,8 +6,10 @@ import com.follow.clash.common.Components
import com.follow.clash.models.SharedState
import com.google.gson.Gson
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.StandardMethodCodec
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -15,32 +17,39 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var messenger: BinaryMessenger
private lateinit var channel: MethodChannel
private lateinit var coreChannel: MethodChannel
private lateinit var scope: CoroutineScope
private val gson = Gson()
private var commandRevision = 0
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
channel = MethodChannel(binding.binaryMessenger, "${Components.PACKAGE_NAME}/service")
messenger = binding.binaryMessenger
channel = MethodChannel(messenger, SERVICE_CHANNEL)
channel.setMethodCallHandler(this)
// Serial: watchRoute keeps the latest intent by the order calls reach the Core.
coreChannel = MethodChannel(
messenger,
CORE_CHANNEL,
StandardMethodCodec.INSTANCE,
messenger.makeBackgroundTaskQueue(),
)
coreChannel.setMethodCallHandler(::onCoreMethodCall)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
coreChannel.setMethodCallHandler(null)
scope.cancel()
ServiceController.setEventListener(null)
}
override fun onMethodCall(call: MethodCall, rawResult: MethodChannel.Result) {
// Most handlers below reply from a scope worker on Dispatchers.Default,
// but a MethodChannel.Result has to be answered on the platform thread.
// Wrapping once here covers every branch, including notImplemented.
val result = MainThreadResult(rawResult)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"init" -> initialize(result)
"shutdown" -> shutdown(result)
"invokeMethod" -> invokeMethod(call, result)
"getRunTime" -> getRunTime(result)
"syncState" -> syncState(call, result)
"start" -> start(call, result)
@@ -49,6 +58,13 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
}
}
private fun onCoreMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"invokeMethod" -> invokeMethod(call, result)
else -> result.notImplemented()
}
}
private fun initialize(result: MethodChannel.Result) {
ServiceController.setEventListener(::sendEvent)
.onSuccess { result.success("") }
@@ -66,9 +82,6 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
result.error("INVALID_ARGUMENT", "Method call payload must be a string", null)
return
}
// Inline rather than a coroutine per call, which could swap two: watchRoute
// keeps the latest intent by the order calls reach the Core
// (dispatchMethodCall in core/method.go), and this only hands data over.
ServiceController.invokeMethod(data) { response ->
result.success(response)
}.onFailure { error ->
@@ -118,9 +131,15 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
}
}
private fun sendEvent(value: String?) {
private fun sendEvent(value: ByteArray) {
val message = StandardMethodCodec.INSTANCE.encodeMethodCall(MethodCall("event", value))
scope.launch(Dispatchers.Main) {
channel.invokeMethod("event", value)
messenger.send(SERVICE_CHANNEL, message)
}
}
private companion object {
val SERVICE_CHANNEL = "${Components.PACKAGE_NAME}/service"
val CORE_CHANNEL = "${Components.PACKAGE_NAME}/core"
}
}
-3
View File
@@ -32,8 +32,5 @@ kotlin {
dependencies {
implementation(libs.androidx.core)
implementation(libs.gson)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.crashlytics.ndk)
implementation(libs.firebase.analytics)
testImplementation(libs.junit)
}
@@ -4,8 +4,6 @@ import android.app.ActivityManager
import android.app.Application
import android.os.Build
import android.util.Log
import com.google.firebase.FirebaseApp
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -36,19 +34,6 @@ object GlobalState : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatch
Log.d("FlClash", text)
}
fun setCrashlytics(enable: Boolean) {
FirebaseApp.initializeApp(application)
FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = enable
if (enable) {
log("Crashlytics enabled")
}
}
fun didCrashOnPreviousExecution(): Boolean {
FirebaseApp.initializeApp(application)
return FirebaseCrashlytics.getInstance().didCrashOnPreviousExecution()
}
fun lastExitInfo(): Map<String, Any?>? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
val manager = application.getSystemService(ActivityManager::class.java) ?: return null
+1 -9
View File
@@ -20,13 +20,6 @@ android {
consumerProguardFiles("consumer-rules.pro")
}
// :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
@@ -249,10 +242,9 @@ val bindCore =
targets.set(gomobileTargets)
androidApi.set(libs.versions.minSdk.get().toInt())
tags.set(buildConfig.map { it["tags"].orEmpty() })
// Crashlytics symbolicates native crashes from the unstripped library :app merges, so the Core keeps its symbols.
ldflags.set(
buildConfig.map { config ->
val flags = config["go_ldflags"].orEmpty().split(" ").filter { it.isNotEmpty() && it != "-s" && it != "-w" }
val flags = config["go_ldflags"].orEmpty().split(" ").filter { it.isNotEmpty() }
val version = config["core_version"].orEmpty()
(flags + listOfNotNull(version.takeIf { it.isNotEmpty() }?.let { "-X=github.com/metacubex/mihomo/constant.Version=$it" }))
.joinToString(" ")
@@ -67,10 +67,10 @@ object Core {
}
fun updateEventListener(
callback: ((result: String?) -> Unit)?,
callback: ((result: ByteArray) -> Unit)?,
) {
Mobile.setEventListener(
callback?.let { ResultHandler { result -> it(result?.decodeToString()) } },
callback?.let { ResultHandler { result -> it(result ?: ByteArray(0)) } },
)
}
-4
View File
@@ -1,5 +1,4 @@
[versions]
firebaseBom = "34.19.0"
minSdk = "24"
targetSdk = "37"
compileSdk = "37"
@@ -14,9 +13,6 @@ kotlinxCoroutines = "1.11.0"
[libraries]
androidx-core = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
firebase-analytics = { module = "com.google.firebase:firebase-analytics" }
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" }
firebase-crashlytics-ndk = { module = "com.google.firebase:firebase-crashlytics-ndk" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
junit = { module = "junit:junit", version.ref = "junit" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
@@ -10,6 +10,7 @@ import android.net.NetworkCapabilities.TRANSPORT_USB
import android.net.NetworkRequest
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.Looper
import androidx.core.content.getSystemService
import com.follow.clash.core.Core
@@ -32,7 +33,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
private val connectivity by lazy {
service.getSystemService<ConnectivityManager>()
}
private val mainHandler = Handler(Looper.getMainLooper())
private val handler = Handler(networkObserveLooper)
private var currentDnsList = listOf<String>()
private var currentNetwork: Network? = null
@@ -56,7 +57,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
info.losingUntilMillis = System.currentTimeMillis() + maxMsToLive
scheduleUpdateDns()
if (maxMsToLive > 0) {
mainHandler.postDelayed({
handler.postDelayed({
if (networkInfos.containsKey(network)) {
updateDns()
}
@@ -80,8 +81,8 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
// 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)
handler.removeCallbacks(updateDnsTask)
handler.postDelayed(updateDnsTask, DNS_UPDATE_DELAY_MILLIS)
}
override fun start() {
@@ -131,7 +132,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
}
override fun stop() {
mainHandler.removeCallbacksAndMessages(null)
handler.removeCallbacksAndMessages(null)
try {
connectivity?.unregisterNetworkCallback(callback)
} finally {
@@ -145,6 +146,10 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
private const val DNS_PORT = 53
private const val DNS_UPDATE_DELAY_MILLIS = 200L
private val networkObserveLooper: Looper by lazy {
HandlerThread("NetworkObserve").apply { start() }.looper
}
private fun InetAddress.asSocketAddressText(port: Int): String = when (this) {
is Inet6Address -> "[$hostAddress]:$port"
is Inet4Address -> "$hostAddress:$port"
-2
View File
@@ -21,8 +21,6 @@ plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
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.8" apply false
}
include(":app")
+1 -8
View File
@@ -108,7 +108,6 @@ private class FakeHost(override val scope: CoroutineScope) : ServiceStateHost {
val toasts = mutableListOf<String>()
val logs = mutableListOf<String>()
val notificationParams = mutableListOf<NotificationParams>()
val crashlytics = mutableListOf<Boolean>()
var setupCalls = 0
var startCalls = 0
var stopCalls = 0
@@ -124,10 +123,6 @@ private class FakeHost(override val scope: CoroutineScope) : ServiceStateHost {
toasts += message
}
override fun setCrashlytics(enabled: Boolean) {
crashlytics += enabled
}
override fun updateNotificationParams(params: NotificationParams) {
notificationParams += params
}
@@ -761,20 +756,18 @@ class ServiceStateMachineTest {
}
@Test
fun `syncSharedState pushes crashlytics and the notification straight through`() = runTest {
fun `syncSharedState pushes the notification straight through`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.syncSharedState(
SharedState(
crashlytics = false,
currentProfileName = "Work",
stopText = "Disconnect",
onlyStatisticsProxy = true,
),
)
assertEquals(listOf(false), host.crashlytics)
assertEquals(listOf(NotificationParams("Work", "Disconnect", true)), host.notificationParams)
}
-4
View File
@@ -45,7 +45,6 @@ class SharedStateTest {
{
"startTip": "Starting",
"stopTip": "Stopping",
"crashlytics": false,
"currentProfileName": "Work",
"stopText": "Halt",
"onlyStatisticsProxy": true,
@@ -78,7 +77,6 @@ class SharedStateTest {
assertEquals("Starting", state.startTip)
assertEquals("Work", state.currentProfileName)
assertEquals(false, state.crashlytics)
assertEquals(true, state.onlyStatisticsProxy)
assertEquals(false, state.showStopAction)
assertEquals(7890, state.vpnOptions?.port)
@@ -97,7 +95,6 @@ class SharedStateTest {
assertEquals("FlClash", defaults.currentProfileName)
assertEquals("Stop", defaults.stopText)
assertEquals(true, defaults.crashlytics)
assertEquals(false, defaults.onlyStatisticsProxy)
assertEquals(true, defaults.showStopAction)
assertNull(defaults.vpnOptions)
@@ -114,7 +111,6 @@ class SharedStateTest {
assertNotNull(state)
assertEquals("Starting VPN...", state.startTip)
assertEquals("FlClash", state.currentProfileName)
assertEquals(true, state.crashlytics)
assertNull(state.vpnOptions)
assertNull(state.setupParams)
}
+88 -54
View File
@@ -13,7 +13,6 @@
"trafficUsage": "Traffic usage",
"networkSpeed": "Network speed",
"outboundMode": "Outbound mode",
"networkDetection": "Network detection",
"serviceStatus": "Service status",
"serviceRestricted": "Access restricted",
"serviceAvailable": "Available",
@@ -42,13 +41,15 @@
"other": "Other",
"about": "About",
"en": "English",
"es": "Spanish",
"fr": "French",
"ja": "Japanese",
"ko": "Korean",
"ru": "Russian",
"zhCN": "Simplified Chinese",
"theme": "Theme",
"themeDesc": "Set dark mode and adjust colors",
"extend": "Extend",
"finalConfig": "Final config",
"runtimeConfig": "Runtime config",
"allowLan": "Allow LAN",
"tun": "TUN",
"tunDesc": "TUN works only in administrator mode.",
@@ -64,7 +65,7 @@
"checkCertificate": "Verify TLS certificates",
"checkCertificateDesc": "With TLS verification off, subscriptions and backups are open to man-in-the-middle attacks.",
"accessControl": "Access control",
"accessControlDesc": "Control which apps use the proxy",
"accessControlDesc": "Access control picks which apps use the proxy.",
"edit": "Edit",
"confirm": "Confirm",
"update": "Update",
@@ -85,7 +86,7 @@
"profileUrlNullValidationDesc": "Please enter the profile URL",
"profileUrlInvalidValidationDesc": "Please enter a valid profile URL",
"autoUpdate": "Auto update",
"autoUpdateInterval": "Auto-update interval (minutes)",
"autoUpdateInterval": "Auto-update interval",
"profileAutoUpdateIntervalNullValidationDesc": "Please enter the auto-update interval",
"profileAutoUpdateIntervalInvalidValidationDesc": "Please enter a valid interval",
"themeMode": "Theme mode",
@@ -180,9 +181,7 @@
"testUrl": "Test URL",
"sync": "Sync",
"exclude": "Hide from recent tasks",
"expand": "Standard",
"shrink": "Compact",
"min": "Minimal",
"compact": "Compact",
"tab": "Tab",
"list": "List",
"delay": "Delay",
@@ -202,6 +201,7 @@
"pureBlackMode": "Pure black mode",
"sidebarBlur": "Sidebar blur",
"sidebarBlurDesc": "Show the blurred desktop behind the window through the sidebar",
"liquidGlass": "Liquid Glass",
"keepAliveIntervalDesc": "TCP keep-alive interval",
"entries": " entries",
"local": "Local",
@@ -216,6 +216,7 @@
"layout": "Layout",
"tight": "Tight",
"standard": "Standard",
"ultra": "Ultra",
"loose": "Loose",
"profilesSort": "Sort profiles",
"start": "Start",
@@ -223,7 +224,6 @@
"stop": "Stop",
"key": "Key",
"value": "Value",
"vpnTip": "Changes take effect after restarting the VPN",
"vpnEnableDesc": "VPN routes all system traffic through VpnService automatically.",
"options": "Options",
"enabled": "Enabled",
@@ -316,26 +316,20 @@
"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.",
"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 Software includes no analytics or crash reporting service on any platform.",
"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",
"disclaimerReadToEnd": "Read to the end",
"disclaimerRestateTitle": "Restate the statement",
"disclaimerRestateTip": "To confirm you understand, enter the following statement in full in the field below:",
"disclaimerRestateHint": "Enter the statement above",
"disclaimerRestateMismatch": "Does not match the statement",
"disclaimerAcknowledgeTitle": "Confirm the statement",
"disclaimerAcknowledgeTip": "To confirm you understand, read the following statement and check the box below:",
"disclaimerAcknowledgeLabel": "I have read and understand this statement",
"agree": "Agree",
"hotkeyManagement": "Hotkey management",
"hotkeyDesc": "Global hotkeys work even while the window is hidden. Tap an action to record its key combination.",
@@ -405,12 +399,9 @@
"lineWrap": "Word wrap",
"none": "None",
"advancedConfig": "Advanced configuration",
"advancedConfigDesc": "Network, DNS, added rules, and scripts",
"selectedCountTitle": "{count} selected",
"advancedConfigDesc": "Advanced configuration holds network, DNS, added rules, and scripts.",
"addRule": "Add rule",
"ruleName": "Rule name",
"content": "Content",
"subRule": "Sub-rule",
"ruleTarget": "Rule target",
"matchTarget": "MATCH-TARGET",
"followProfile": "Follow profile",
@@ -424,17 +415,19 @@
"slide": "Slide",
"fade": "Fade",
"pureBlack": "Pure black",
"colorSchemes": "Color schemes",
"palette": "Palette",
"tonalSpotScheme": "Tonal spot",
"fidelityScheme": "Fidelity",
"customColor": "Custom color",
"colorStyle": "Color style",
"grid": "Grid",
"spectrum": "Spectrum",
"sliders": "Sliders",
"hue": "Hue",
"saturation": "Saturation",
"brightness": "Brightness",
"hex": "Hex",
"monochromeScheme": "Monochrome",
"neutralScheme": "Neutral",
"vibrantScheme": "Vibrant",
"expressiveScheme": "Expressive",
"contentScheme": "Content",
"rainbowScheme": "Rainbow",
"fruitSaladScheme": "Fruit salad",
"vividStyle": "Vivid",
"tintedStyle": "Tinted",
"softStyle": "Soft",
"developerMode": "Developer mode",
"developerModeEnableTip": "Developer mode is enabled.",
"messageTest": "Message test",
@@ -514,10 +507,6 @@
"forceRestartCoreTip": "Are you sure you want to force restart the core?",
"dnsHijacking": "DNS hijacking",
"coreStatus": "Core status",
"dataCollectionTip": "Data collection notice",
"dataCollectionContent": "This app uses Firebase Crashlytics to collect crash information to improve stability.\nThe collected data includes device information and crash details, and contains no personally sensitive data.\nYou can turn this off in settings.",
"crashlytics": "Crash analytics",
"crashlyticsTip": "Crash analytics uploads a crash log without sensitive information when the app crashes.",
"appendSystemDns": "Append system DNS",
"editRule": "Edit rule",
"extendMode": "Extend mode",
@@ -562,7 +551,7 @@
"customProfile": "Custom",
"customProfileDesc": "Assemble proxy groups and rules from app proxies and other profiles",
"customProfileResources": "Custom profile resources",
"customProfileResourcesDesc": "Proxies, rule providers, filters, and icon sets",
"customProfileResourcesDesc": "Custom profile resources hold proxies, rule providers, filters, and icon sets.",
"unknownNetworkError": "Unknown network error",
"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",
@@ -579,7 +568,6 @@
"invalidBackupFile": "Invalid backup file",
"pruneCache": "Prune cache",
"backupAndRestore": "Backup and restore",
"backupAndRestoreDesc": "Sync data via WebDAV or files",
"restore": "Restore",
"restoreSuccess": "Restore successful",
"restoreOnlyConfig": "Restore profiles only",
@@ -587,9 +575,7 @@
"addProfile": "Add profile",
"delayTest": "Delay test",
"proxyGroupEmpty": "Proxy group is empty",
"proxyGroupNameDuplicate": "Duplicate proxy group name",
"confirmExitWindow": "Are you sure you want to exit the current window?",
"selectProxyProviders": "Select proxy providers",
"optional": "Optional",
"maxFailedTimes": "Max failures",
"testInterval": "Test interval",
@@ -597,7 +583,6 @@
"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.",
"tunAuthorizationFailed": "TUN could not be authorized, so it was turned off.",
@@ -614,7 +599,6 @@
"selectRuleSet": "Please select a rule set",
"splitStrategy": "Split strategy",
"selectSplitStrategy": "Please select a split strategy",
"selectSubRule": "Please select a sub-rule",
"noResolveHostname": "Don't resolve hostname",
"matchSourceIp": "Match source IP",
"basicInfo": "Basic info",
@@ -622,14 +606,11 @@
"proxyType": "Proxy type",
"basicStrategy": "Basic strategies",
"editProxy": "Edit proxy",
"addProxyProviders": "Add proxy providers",
"proxiesEmpty": "Proxies are empty",
"addNodes": "Add nodes",
"addProxyGroup": "Add proxy group",
"editProxyGroup": "Edit proxy group",
"iconUrl": "Icon URL",
"iconRecords": "Icon records",
"noRecords": "No records",
"custom": "Custom",
"ruleActionDomainDesc": "Match the full domain",
"ruleActionDomainSuffixDesc": "Match a domain suffix",
@@ -668,12 +649,8 @@
"ruleActionNotDesc": "Logical rule NOT",
"ruleActionSubRuleDesc": "Match into a sub-rule; mind the parentheses",
"ruleActionMatchDesc": "Match all requests, no conditions needed",
"subRuleEmpty": "Sub-rule is empty",
"proxyProvidersNotEmpty": "Proxy providers cannot be empty",
"contentNotEmpty": "Content cannot be empty",
"subRuleNotEmpty": "Sub-rule cannot be empty",
"splitStrategyNotEmpty": "Split strategy cannot be empty",
"proxyProvidersEmpty": "Proxy providers are empty",
"timeout": "Timeout",
"delayFailed": "Failed",
"invalidSubRule": "{subRule} is an invalid SUB_RULE",
@@ -709,13 +686,14 @@
"resourceUpdateInterval": "Auto-update interval",
"resourceUpdateIntervalTip": "The auto-update interval must be greater than 0",
"hours": "hours",
"hoursCount": "{count, plural, =1{1 hour} other{{count} hours}}",
"minutes": "minutes",
"geoResources": "Geo",
"geoSkipped": "{name} is already up to date",
"geoUpdated": "{name} updated",
"secondsCount": "{count, plural, =1{1 second} other{{count} seconds}}",
"proxiesCount": "{count, plural, =1{1 proxy} other{{label} proxies}}",
"rulesCount": "{count, plural, =1{1 rule} other{{label} rules}}",
"proxyGroupsCount": "{count, plural, =1{1 proxy group} other{{label} proxy groups}}",
"changelogBreaking": "Breaking changes",
"changelogFeatures": "New features",
"changelogFixes": "Bug fixes",
@@ -740,7 +718,6 @@
"nextMatch": "Next match",
"clearSearch": "Clear search",
"noSearchResults": "No matching results",
"addWidget": "Add widget",
"showPassword": "Show password",
"hidePassword": "Hide password",
"authorize": "Authorize",
@@ -832,6 +809,7 @@
"filters": "Filters",
"label": "Label",
"regex": "Regex",
"matchCase": "Match case",
"iconSets": "Icon sets",
"invalidIconSet": "Not a valid icon set",
"recommendedIcons": "Suggested",
@@ -840,6 +818,7 @@
"outbound": "Outbound",
"strictRoute": "Strict route",
"congestionController": "TCP congestion control",
"congestionControllerDesc": "TCP congestion control works only with the mips stack.",
"routeExcludeAddress": "Route exclude addresses",
"keepAliveIdle": "TCP keep-alive idle",
"disableKeepAlive": "Disable TCP keep-alive",
@@ -856,7 +835,22 @@
"disableIcmpForwarding": "Disable ICMP forwarding",
"disableIcmpForwardingDesc": "With ICMP forwarding disabled, TUN answers ping itself, so ping no longer shows the real latency.",
"excludeInterface": "Excluded interfaces",
"excludeInterfaceDesc": "Excluded interfaces work only on Linux: traffic arriving on them, such as docker0, skips TUN.",
"excludeInterfaceDesc": "Traffic arriving on excluded interfaces, such as docker0, skips TUN.",
"includeInterface": "Included interfaces",
"includeInterfaceDesc": "Only traffic arriving on included interfaces goes through TUN, and excluded interfaces no longer apply.",
"includeUid": "Included user IDs",
"includeUidDesc": "Only traffic from processes of included user IDs goes through TUN.",
"excludeUid": "Excluded user IDs",
"excludeUidDesc": "Traffic from processes of excluded user IDs skips TUN.",
"excludeSrcPort": "Excluded source ports",
"excludeSrcPortDesc": "Traffic from excluded source ports skips TUN.",
"excludeDstPort": "Excluded destination ports",
"excludeDstPortDesc": "Traffic to excluded destination ports skips TUN.",
"udpTimeout": "UDP timeout",
"udpTimeoutDesc": "The UDP timeout is in seconds and works only with the gvisor, system and mixed stacks.",
"dnsHijackDesc": "Without this entry, TUN hijacks every DNS query to port 53 (any:53).",
"tunOverrideDesc": "Entries added here replace the TUN settings of every profile; anything not added keeps the profile's own setting. Entries a custom profile adds itself take precedence.",
"profileTunSettingsDesc": "Applies to this profile only and takes precedence over the TUN override entries in the network settings.",
"settingsCount": "{count, plural, =1{1 setting} other{{count} settings}}",
"sectionsInEffect": "{count} in effect",
"itemsCount": "{count, plural, =1{1 item} other{{count} items}}",
@@ -905,5 +899,45 @@
"required": "Required",
"ageSecretKey": "Age secret key",
"ageEncryptedProfile": "Encrypted profile",
"ageSecretKeyRequiredTip": "This profile is encrypted with age and needs an age secret key to decrypt"
"ageSecretKeyRequiredTip": "This profile is encrypted with age and needs an age secret key to decrypt",
"currentOutboundDesc": "Holds the start button and can't be removed",
"running": "Running",
"notRunning": "Not running",
"trafficPeak": "Peak {value}",
"trafficTotal": "Total {value}",
"ipQualityShort": "Quality",
"ipTypeUnknown": "Unknown type",
"tapIconToRetry": "Tap the icon to retry",
"networkTakeover": "Traffic takeover",
"portValue": "Port {port}",
"serviceAbnormalCount": "{count, plural, =1{1 issue} other{{count} issues}}",
"serviceAllAvailable": "All available",
"serviceShownOnDashboard": "Shown on the dashboard",
"groupTimeoutCount": "{count} timed out",
"groupCountUnit": "groups",
"groupCount": "{count, plural, =1{1 group} other{{count} groups}}",
"proxyGroupsAfterStart": "Groups show up here once the core starts",
"usedShort": "used",
"profileUpdatedAt": "Updated {time}",
"profileExpiresIn": "Expires {date}, {days, plural, =1{1 day} other{{days} days}} left",
"profileDaysLeft": "{days, plural, =1{1 day left} other{{days} days left}}",
"target": "Target",
"outboundExit": "Exit",
"duration": "Duration",
"traffic": "Traffic",
"totalDownload": "Downloaded",
"totalUpload": "Uploaded",
"initiatorShort": "From",
"result": "Result",
"elapsed": "Took",
"connectionStats": "Connection stats",
"connectionStatsDesc": "Live connections, recent requests and DNS queries",
"memoryInfoDesc": "Memory the app and the core use",
"editDashboard": "Edit dashboard",
"done": "Done",
"dashboardAddableSection": "Available to add",
"customImportDroppedSubRules": "{count, plural, =1{1 SUB-RULE rule was dropped, since custom profiles do not support sub-rules} other{{label} SUB-RULE rules were dropped, since custom profiles do not support sub-rules}}",
"autoInclude": "Auto-include",
"allReferencedProxies": "All referenced proxies",
"allReferencedProxyProviders": "All referenced proxy providers"
}
+943
View File
@@ -0,0 +1,943 @@
{
"rule": "Regla",
"rules": "Reglas",
"global": "Global",
"direct": "Directo",
"dashboard": "Panel",
"proxies": "Proxies",
"profile": "Perfil",
"profiles": "Perfiles",
"tools": "Herramientas",
"logs": "Registros",
"resources": "Recursos",
"trafficUsage": "Uso de tráfico",
"networkSpeed": "Velocidad de red",
"outboundMode": "Modo de salida",
"serviceStatus": "Estado del servicio",
"serviceRestricted": "Acceso restringido",
"serviceAvailable": "Disponible",
"serviceUnavailable": "No disponible",
"serviceFailed": "Error en la comprobación",
"serviceDisallowedIsp": "ISP no permitido",
"serviceBlocked": "Bloqueado",
"serviceUnsupportedRegion": "Región no compatible",
"serviceOriginalsOnly": "Solo originales",
"serviceComingSoon": "Próximamente",
"servicePending": "Sin comprobar",
"serviceManage": "Gestionar servicios",
"serviceCheck": "Comprobar",
"serviceCheckAll": "Comprobar todo",
"serviceCheckedAt": "Comprobado a las {time}",
"upload": "Subida",
"download": "Descarga",
"usedTraffic": "Tráfico usado",
"totalTraffic": "Tráfico total",
"expireTime": "Fecha de vencimiento",
"nullProfileDesc": "Añade un perfil para empezar",
"settings": "Ajustes",
"language": "Idioma",
"defaultText": "Predeterminado",
"more": "Más",
"other": "Otros",
"about": "Acerca de",
"en": "Inglés",
"es": "Español",
"fr": "Francés",
"ja": "Japonés",
"ko": "Coreano",
"ru": "Ruso",
"zhCN": "Chino simplificado",
"theme": "Tema",
"extend": "Extensión",
"runtimeConfig": "Configuración de ejecución",
"allowLan": "Permitir LAN",
"tun": "TUN",
"tunDesc": "TUN solo funciona en modo administrador.",
"minimizeOnExit": "Minimizar al salir",
"autoLaunch": "Inicio automático",
"silentLaunch": "Inicio silencioso",
"silentLaunchDesc": "El inicio silencioso arranca sin mostrar la ventana.",
"autoRun": "Ejecución automática",
"autoRunDesc": "La ejecución automática inicia el proxy al abrir la app.",
"logcat": "Logcat",
"logcatDesc": "Si desactivas Logcat, se oculta la entrada de registros.",
"autoCheckUpdate": "Buscar actualizaciones automáticamente",
"checkCertificate": "Verificar certificados TLS",
"checkCertificateDesc": "Con la verificación TLS desactivada, las suscripciones y las copias de seguridad quedan expuestas a ataques de intermediario.",
"accessControl": "Control de acceso",
"accessControlDesc": "El control de acceso elige qué apps usan el proxy.",
"edit": "Editar",
"confirm": "Confirmar",
"update": "Actualizar",
"add": "Añadir",
"save": "Guardar",
"delete": "Eliminar",
"seconds": "segundos",
"qrcode": "Código QR",
"qrcodeDesc": "Escanea un código QR para obtener un perfil",
"url": "URL",
"link": "Enlace",
"linkDesc": "Obtén un perfil desde un enlace de suscripción o un enlace para compartir proxies",
"file": "Archivo",
"fileDesc": "Sube un archivo de perfil directamente",
"name": "Nombre",
"profileNameNullValidationDesc": "Introduce el nombre del perfil",
"rename": "Renombrar",
"profileUrlNullValidationDesc": "Introduce la URL del perfil",
"profileUrlInvalidValidationDesc": "Introduce una URL de perfil válida",
"autoUpdate": "Actualización automática",
"autoUpdateInterval": "Intervalo de actualización automática",
"profileAutoUpdateIntervalNullValidationDesc": "Introduce el intervalo de actualización automática",
"profileAutoUpdateIntervalInvalidValidationDesc": "Introduce un intervalo válido",
"themeMode": "Modo del tema",
"themeColor": "Color del tema",
"preview": "Vista previa",
"auto": "Automático",
"light": "Claro",
"dark": "Oscuro",
"importFromLink": "Importar desde enlace",
"submit": "Enviar",
"createProfileFromUrlTip": "¿Quieres crear un perfil a partir de {url}?",
"create": "Crear",
"pleaseUploadValidQrcode": "Sube un código QR válido",
"blacklistMode": "Modo lista negra",
"whitelistMode": "Modo lista blanca",
"selectAll": "Seleccionar todo",
"cancelSelectAll": "Deseleccionar todo",
"appAccessControl": "Control de acceso de apps",
"accessControlAllowDesc": "Solo las apps seleccionadas usan la VPN",
"accessControlNotAllowDesc": "Las apps seleccionadas quedan excluidas de la VPN",
"accessControlDisabledDesc": "El control de acceso de apps está desactivado",
"selected": "Seleccionado",
"port": "Puerto",
"logLevel": "Nivel de registro",
"show": "Mostrar",
"exit": "Salir",
"systemProxy": "Proxy del sistema",
"safeMode": "Modo seguro",
"safeModeAppTitle": "{appName} (Modo seguro)",
"project": "Proyecto",
"core": "Núcleo",
"tabAnimation": "Animación de pestañas",
"desc": "Un cliente proxy multiplataforma basado en ClashMeta, sencillo de usar, de código abierto y sin anuncios.",
"startVpn": "Iniciando VPN…",
"stopVpn": "Deteniendo VPN…",
"localNetworkDeniedTip": "Permiso de red local denegado: con la pila gvisor, la LAN no es accesible.",
"compatible": "Modo de compatibilidad",
"notSelectedTip": "No se puede seleccionar el grupo de proxies actual",
"changeProxyFailedTip": "No se pudo cambiar de proxy; se ha restaurado la selección anterior",
"databaseWriteFailedTip": "No se pudo guardar el cambio; se ha revertido",
"tip": "Consejo",
"account": "Cuenta",
"backup": "Copia de seguridad",
"backupSuccess": "Copia de seguridad completada",
"noInfo": "Sin información",
"pleaseBindWebDAV": "Vincula WebDAV",
"bind": "Vincular",
"connectivity": "Conectividad: ",
"webDAVConfiguration": "Configuración de WebDAV",
"address": "Dirección",
"addressHelp": "Dirección del servidor WebDAV",
"addressTip": "Introduce una dirección WebDAV válida",
"password": "Contraseña",
"authentication": "Autenticación",
"authenticationDesc": "La autenticación pide usuario y contraseña en el puerto del proxy local.",
"authenticationSystemProxyDesc": "El proxy del sistema no se aplica mientras la autenticación esté activada.",
"checkUpdate": "Buscar actualizaciones",
"discoverNewVersion": "Nueva versión disponible",
"checkUpdateError": "La app ya está actualizada",
"goDownload": "Descargar",
"unknown": "Desconocido",
"country": "Región",
"search": "Buscar",
"allowBypass": "Permitir que las apps eludan la VPN",
"externalController": "Controlador externo",
"externalControllerDesc": "El controlador externo permite controlar el núcleo en el puerto 9090.",
"externalControllerSecret": "Clave del controlador externo",
"ipv6Desc": "El tráfico IPv6 solo se recibe mientras IPv6 esté activado.",
"app": "App",
"general": "General",
"startupAndBackground": "Inicio y segundo plano",
"requestsAndUpdates": "Solicitudes y actualizaciones",
"logsAndDiagnostics": "Registros y diagnóstico",
"userAgent": "User-Agent",
"unifiedDelay": "Latencia unificada",
"tcpConcurrent": "TCP concurrente",
"geodataLoader": "Modo geo de poca memoria",
"requests": "Solicitudes",
"recentRequests": "Solicitudes recientes",
"findProcessMode": "Buscar proceso",
"init": "Inicio",
"infiniteTime": "No caduca",
"connections": "Conexiones",
"liveConnections": "Conexiones activas",
"inbound": "Entrada",
"intranetIP": "IP de intranet",
"view": "Ver",
"cut": "Cortar",
"copy": "Copiar",
"clipboardWriteFailed": "No se pudo copiar al portapapeles. Puede que la selección sea demasiado grande",
"paste": "Pegar",
"testUrl": "URL de prueba",
"sync": "Sincronizar",
"exclude": "Ocultar de aplicaciones recientes",
"compact": "Compacto",
"tab": "Pestaña",
"list": "Lista",
"delay": "Latencia",
"style": "Estilo",
"size": "Tamaño",
"sort": "Ordenar",
"columns": "Columnas",
"proxyGroup": "Grupo de proxies",
"go": "Ir",
"externalLink": "Enlace externo",
"otherContributors": "Otros colaboradores",
"appIconDesign": "Diseño del icono de la app",
"autoCloseConnections": "Cerrar conexiones automáticamente",
"autoCloseConnectionsDesc": "Cerrar conexiones automáticamente corta las conexiones abiertas al cambiar de proxy.",
"onlyStatisticsProxy": "Contar solo el tráfico del proxy",
"showNotificationStopAction": "Botón de detener en la notificación",
"pureBlackMode": "Modo negro puro",
"sidebarBlur": "Desenfoque de la barra lateral",
"sidebarBlurDesc": "Muestra el escritorio desenfocado detrás de la ventana a través de la barra lateral",
"liquidGlass": "Liquid Glass",
"keepAliveIntervalDesc": "Intervalo de keep-alive TCP",
"entries": " entradas",
"local": "Local",
"remote": "Remoto",
"mode": "Modo",
"time": "Hora",
"source": "Origen",
"action": "Acción",
"intelligentSelected": "Selección inteligente",
"clipboardImport": "Importar desde el portapapeles",
"clipboardExport": "Exportar al portapapeles",
"layout": "Diseño",
"tight": "Ajustado",
"standard": "Estándar",
"ultra": "Ultra",
"loose": "Amplio",
"profilesSort": "Ordenar perfiles",
"start": "Iniciar",
"runTime": "Tiempo de ejecución",
"stop": "Detener",
"key": "Clave",
"value": "Valor",
"vpnEnableDesc": "La VPN enruta automáticamente todo el tráfico del sistema a través de VpnService.",
"options": "Opciones",
"enabled": "Activado",
"disabled": "Desactivado",
"loopback": "Exención de loopback de UWP",
"providers": "Recursos externos",
"proxyProviders": "Proveedores de proxies",
"ruleProviders": "Proveedores de reglas",
"providerUrlTip": "Solo se admiten proveedores remotos",
"ruleSetEmptyTip": "El conjunto de reglas no tiene entradas utilizables",
"ruleSetMixedTip": "El conjunto de reglas mezcla dominios y rangos de IP, por lo que no se puede determinar su tipo",
"ruleSetInvalidTip": "No se puede leer este archivo mrs",
"subscriptionInfo": "Información de la suscripción",
"ntpStatusDesc": "NTP obtiene la hora de un servidor NTP en lugar del reloj del sistema.",
"server": "Servidor",
"ntpInterval": "Intervalo de sincronización (minutos)",
"dialerProxy": "Proxy de marcación",
"dialerProxyDesc": "El proxy de marcación es la salida que se usa para llegar al servidor NTP.",
"writeToSystem": "Escribir en el sistema",
"writeToSystemDesc": "Escribir en el sistema también ajusta el reloj del sistema; Android lo ignora.",
"sniffer": "Sniffer",
"snifferStatusDesc": "El sniffer lee el dominio del tráfico TLS, HTTP y QUIC para que las reglas puedan aplicarse.",
"overrideDestination": "Sobrescribir destino",
"overrideDestinationDesc": "Sobrescribir destino se conecta al dominio detectado en lugar de a la dirección original.",
"forceDnsMapping": "Forzar mapeo DNS",
"forceDnsMappingDesc": "Forzar mapeo DNS también analiza las conexiones cuyo dominio provino de un mapeo DNS.",
"parsePureIp": "Analizar IP pura",
"parsePureIpDesc": "Analizar IP pura analiza las conexiones que solo llevan una dirección IP.",
"forceDomain": "Forzar dominios",
"forceDomainDesc": "Los dominios forzados también se analizan; las demás conexiones que ya tienen dominio se dejan como están.",
"skipDomain": "Omitir dominios",
"skipDomainDesc": "Un dominio detectado que esté en la lista de dominios omitidos no se usa.",
"skipSrcAddress": "Omitir direcciones de origen",
"skipDstAddress": "Omitir direcciones de destino",
"sniffProtocols": "Protocolos",
"ports": "Puertos",
"overrideEntries": "Entradas de sobrescritura",
"addOverrideEntry": "Añadir entrada de sobrescritura",
"dnsOverrideDesc": "Las entradas añadidas aquí sustituyen el DNS de todos los perfiles de URL y de archivo; lo que no añadas conserva el ajuste del propio perfil. Los perfiles personalizados definen su DNS en su contenido.",
"settingEntries": "ajustes",
"addSettingEntry": "Añadir ajuste",
"profileSettingsDesc": "Se aplica solo a este perfil; lo que no añadas usa su valor predeterminado.",
"healthCheck": "Comprobación de estado",
"discardChanges": "¿Descartar los cambios?",
"quickEdit": "Edición rápida",
"status": "Estado",
"statusDesc": "Con DNS desactivado, se usa el DNS del sistema.",
"respectRules": "Respetar reglas",
"respectRulesDesc": "Respetar reglas enruta las conexiones DNS según las reglas y requiere un Proxy Server Nameserver.",
"dnsMode": "Modo DNS",
"fakeipRange": "Rango Fake-IP",
"fakeipFilter": "Filtro Fake-IP",
"useHosts": "Usar hosts",
"useSystemHosts": "Usar hosts del sistema",
"fallbackFilter": "Filtro de respaldo",
"domain": "Dominio",
"listenRoutingMark": "Marca de enrutamiento de escucha",
"listenRoutingMarkDesc": "La marca de enrutamiento de escucha solo funciona en Linux.",
"ipv6Timeout": "Tiempo de espera IPv6 (ms)",
"cacheAlgorithm": "Algoritmo de caché",
"cacheMaxSize": "Tamaño de caché",
"fakeipRange6": "Rango Fake-IP (IPv6)",
"fakeipFilterMode": "Modo de filtro Fake-IP",
"fakeipFilterModeDesc": "En el modo de filtro Fake-IP, la lista negra excluye las coincidencias, la lista blanca aplica Fake-IP solo a las coincidencias y rule coincide según las reglas.",
"fakeipTtl": "TTL de Fake-IP",
"reset": "Restablecer",
"actionView": "Mostrar/Ocultar",
"actionStart": "Iniciar/Detener",
"actionMode": "Cambiar modo",
"actionProxy": "Proxy del sistema",
"actionTun": "TUN",
"actionRuleMode": "Modo regla",
"actionGlobalMode": "Modo global",
"actionDirectMode": "Modo directo",
"actionDelayTest": "Probar la latencia de todos",
"actionUpdateProfiles": "Actualizar perfiles",
"disclaimer": "Aviso legal",
"disclaimerDesc": "Antes de usar FlClash (“el Software”), lee con atención esta declaración y asegúrate de entenderla por completo. Pulsar “Aceptar” significa que has leído, comprendido y aceptado todos los términos siguientes. Si no estás de acuerdo, pulsa “Salir” y deja de usar el Software.",
"disclaimerSoftwareTitle": "Naturaleza del software",
"disclaimerSoftwareContent": "El Software es un cliente proxy de red de código abierto basado en el núcleo Clash.Meta (mihomo). Solo ofrece herramientas locales como la gestión de configuración, el enrutamiento basado en reglas y el reenvío de tráfico.",
"disclaimerNoServiceStatement": "El Software en sí no proporciona ningún servidor proxy, nodo, suscripción ni servicio de acceso a la red, y no mantiene ninguna relación de asociación, representación ni garantía con ningún proveedor de dichos servicios.",
"disclaimerUsageTitle": "Restricciones de uso",
"disclaimerUsageContent": "El Software está destinado únicamente a usos no comerciales, como el aprendizaje, el intercambio y la investigación técnica. Queda estrictamente prohibido usarlo con cualquier fin comercial, incluidos, entre otros, la distribución de pago, la venta en paquete, su uso como parte de un servicio comercial o hacer negocio en nombre del Software. Cualquier actividad comercial no guarda relación con el Software ni con sus desarrolladores.\n\nQueda estrictamente prohibido usar el Software para cualquier fin que infrinja las leyes y normativas de tu país o región, incluidos, entre otros, eludir restricciones de acceso a la red impuestas legalmente, difundir contenido ilegal, lanzar ataques a la red o vulnerar los derechos legítimos de otras personas.",
"disclaimerResponsibilityTitle": "Tu responsabilidad",
"disclaimerResponsibilityContent": "Eres responsable de asegurarte de que el uso del Software sea legal donde vives, y asumes tú solo la responsabilidad legal de todo lo que hagas con él y de sus consecuencias.\n\nLas suscripciones, los nodos y las configuraciones que importas son decisión tuya. Que su origen sea legal, su contenido seguro y su servicio fiable es un asunto entre tú y sus proveedores.",
"disclaimerThirdPartyTitle": "Contenido de terceros",
"disclaimerThirdPartyContent": "Los enlaces de suscripción, los archivos de configuración, los conjuntos de reglas, los scripts, los recursos externos y los enlaces externos los proporcionan terceros. Los desarrolladores no pueden revisar ni garantizar, ni lo hacen, su legalidad, exactitud, seguridad o disponibilidad.\n\nCualquier fuga de datos, pérdida económica, bloqueo de cuenta u otra pérdida causada por contenido de terceros debe resolverse entre tú y el tercero; los desarrolladores no asumen ninguna responsabilidad por ello.",
"disclaimerWarrantyTitle": "Sin garantía",
"disclaimerWarrantyContent": "El Software se proporciona “tal cual” y “según disponibilidad”, sin garantía de ningún tipo, expresa o implícita, incluidas, entre otras, las garantías de comerciabilidad, idoneidad para un fin concreto, no infracción, disponibilidad ininterrumpida, ausencia de errores o ausencia de vulnerabilidades de seguridad.\n\nLos desarrolladores no garantizan que el Software satisfaga tus necesidades ni que funcione sin interrupciones ni errores.",
"disclaimerLiabilityTitle": "Limitación de responsabilidad",
"disclaimerLiabilityContent": "En la máxima medida permitida por la ley aplicable, ni los desarrolladores ni ningún colaborador serán responsables de ningún daño directo, indirecto, incidental, especial, punitivo o emergente derivado del uso o de la imposibilidad de usar el Software, incluidos, entre otros, la pérdida de datos, los daños al dispositivo, los fallos de red, la interrupción de la actividad, el lucro cesante o cualquier disputa legal resultante, incluso si se les hubiera advertido de la posibilidad de tales daños.",
"disclaimerPrivacyTitle": "Recopilación de datos y privacidad",
"disclaimerPrivacyContent": "El Software no recopila ni sube tus URL de suscripción, los detalles de los nodos, el contenido de las configuraciones, los sitios web visitados, los registros de conexión, el contenido del tráfico ni los registros. Estos datos permanecen en tu dispositivo y los desarrolladores no tienen acceso a ellos.\n\nEl Software solo accede a la red cuando usas una función que lo necesita, como obtener la URL de suscripción que indicaste al actualizar un perfil o conectarse a GitHub al buscar actualizaciones.\n\nEl Software no incluye ningún servicio de analítica ni de informes de fallos en ninguna plataforma.",
"disclaimerLicenseTitle": "Licencia de código abierto",
"disclaimerLicenseContent": "El Software es de código abierto bajo la licencia GPL-3.0. Puedes usarlo, modificarlo y distribuirlo libremente siempre que cumplas dicha licencia, que exige que las obras derivadas se publiquen también bajo GPL-3.0 y que se conserven los avisos de derechos de autor originales.\n\nLos componentes de terceros del Software, incluido el núcleo Clash.Meta, siguen sus propias licencias. Los autores originales no se hacen responsables de ningún problema derivado de versiones modificadas o redistribuidas.",
"disclaimerChangesTitle": "Cambios en esta declaración",
"disclaimerChangesContent": "Los desarrolladores pueden revisar esta declaración con cualquier versión, y la revisión entra en vigor cuando se publique dicha versión. Seguir usando el Software después de actualizar implica que aceptas la declaración revisada.",
"disclaimerAcceptTitle": "Aceptación",
"disclaimerAcceptContent": "Al instalar, copiar o usar el Software, se considera que has leído y aceptado esta declaración en su totalidad. Si no estás de acuerdo con alguno de sus términos, deja de usar el Software y desinstálalo de inmediato.",
"disclaimerRestateTitle": "Repite la declaración",
"disclaimerRestateTip": "Para confirmar que lo entiendes, escribe la siguiente declaración completa en el campo de abajo:",
"disclaimerRestateHint": "Escribe la declaración anterior",
"disclaimerRestateMismatch": "No coincide con la declaración",
"disclaimerAcknowledgeTitle": "Confirma la declaración",
"disclaimerAcknowledgeTip": "Para confirmar que lo entiendes, lee la siguiente declaración y marca la casilla de abajo:",
"disclaimerAcknowledgeLabel": "He leído y entiendo esta declaración",
"agree": "Aceptar",
"hotkeyManagement": "Gestión de atajos",
"hotkeyDesc": "Los atajos globales funcionan incluso con la ventana oculta. Toca una acción para grabar su combinación de teclas.",
"hotkeyNotSet": "Sin asignar",
"pressKeyboard": "Pulsa una combinación de teclas",
"hotkeyNeedsModifier": "Incluye al menos una de estas teclas: {modifiers}",
"hotkeyConflictWith": "Ya lo usa “{action}”. Al guardar, pasará a este.",
"hotkeyUnavailable": "No registrado; puede que otra app lo esté usando",
"remove": "Quitar",
"noNetwork": "Sin red",
"ipv6InboundDesc": "Con IPv6 activado, la VPN permite el tráfico IPv6 entrante.",
"exportLogs": "Exportar registros",
"exportSuccess": "Exportación completada",
"iconStyle": "Estilo de icono",
"iconStyleFilled": "Relleno",
"iconStylePlain": "Simple",
"iconStyleHidden": "Oculto",
"filter": "Filtro",
"hideTimeoutProxies": "Ocultar proxies sin respuesta",
"hideTimeoutProxiesDesc": "Omite los proxies cuya última prueba de latencia superó el tiempo de espera",
"stackMode": "Modo de pila",
"network": "Red",
"bypassDomain": "Dominios excluidos",
"bypassDomainDesc": "Los dominios excluidos solo se aplican mientras el proxy del sistema esté activado.",
"resetTip": "¿Seguro que quieres restablecer?",
"icon": "Icono",
"noData": "Sin datos",
"fontFamily": "Familia de fuente",
"installedFonts": "Fuentes instaladas",
"toggle": "Alternar",
"system": "Sistema",
"routeAddress": "Direcciones de ruta",
"interfaceNameMode": "Interfaz de salida",
"interfaceNameModeClear": "Ninguna",
"interfaceNameModeFollow": "Seguir configuración",
"interfaceNameModeCustom": "Personalizada",
"interfaceName": "Nombre de interfaz",
"interfaceNameDesc": "El nombre de interfaz es la interfaz de red que usan las conexiones salientes.",
"copyEnvVar": "Copiar variables de entorno",
"memoryInfo": "Información de memoria",
"total": "Total",
"releaseMemory": "Liberar memoria",
"memoryCoreHeapInuse": "Heap en uso",
"memoryCoreHeapIdle": "Heap inactivo",
"memoryCoreStack": "Pilas de goroutines",
"memoryCoreRuntime": "Sobrecarga del runtime",
"memoryCoreNotRunning": "El núcleo no se está ejecutando",
"memoryAppResident": "Memoria residente",
"memoryAppShared": "App y compartida",
"memoryEstimateDesc": "Estimado a partir de la memoria residente del proceso; puede diferir de lo que indica el sistema.",
"memoryEstimateSharedDesc": "El núcleo se ejecuta dentro del proceso de la app. Su parte se estima a partir de las estadísticas del runtime, y el resto cuenta como memoria de la app y compartida.",
"memoryReleased": "Memoria liberada",
"memoryReleasedSize": "Liberado: {size}",
"releaseMemoryFailed": "No se pudo liberar la memoria",
"cancel": "Cancelar",
"fileIsUpdate": "El archivo se ha modificado. ¿Guardar los cambios?",
"profileHasUpdate": "El perfil se ha modificado. ¿Desactivar la actualización automática?",
"hasCacheChange": "¿Guardar los cambios en la caché?",
"copySuccess": "Copiado correctamente",
"copyLink": "Copiar enlace",
"exportFile": "Exportar archivo",
"cacheCorrupt": "La caché está dañada. ¿Borrarla?",
"detectionTip": "Depende de una API de terceros; solo con fines informativos",
"listen": "Escucha",
"undo": "Deshacer",
"redo": "Rehacer",
"lineWrap": "Ajuste de línea",
"none": "Ninguno",
"advancedConfig": "Configuración avanzada",
"advancedConfigDesc": "La configuración avanzada incluye la red, el DNS, las reglas añadidas y los scripts.",
"addRule": "Añadir regla",
"content": "Contenido",
"ruleTarget": "Destino de la regla",
"matchTarget": "MATCH-TARGET",
"followProfile": "Seguir perfil",
"sourceIp": "IP de origen",
"noResolve": "No resolver IP",
"saveChanges": "¿Guardar los cambios?",
"cannotSave": "No se puede guardar",
"navigationBarStyle": "Barra inferior",
"floating": "Flotante",
"docked": "Fija",
"slide": "Deslizar",
"fade": "Fundido",
"pureBlack": "Negro puro",
"customColor": "Color personalizado",
"colorStyle": "Estilo de color",
"grid": "Cuadrícula",
"spectrum": "Espectro",
"sliders": "Reguladores",
"hue": "Tono",
"saturation": "Saturación",
"brightness": "Brillo",
"hex": "Hex",
"monochromeScheme": "Monocromo",
"vividStyle": "Vivo",
"tintedStyle": "Tintado",
"softStyle": "Suave",
"developerMode": "Modo desarrollador",
"developerModeEnableTip": "El modo desarrollador está activado.",
"messageTest": "Prueba de mensaje",
"messageTestTip": "Esto es un mensaje.",
"crashTest": "Prueba de fallo",
"crashDetected": "Fallo detectado",
"crashDetectedTip": "La app no pudo terminar de iniciarse dos veces seguidas. Para romper el ciclo, se ha deseleccionado el perfil {name} y se ha omitido la configuración automática. Puedes volver a seleccionarlo en cualquier momento.",
"launchInterrupted": "El inicio no se completó",
"launchInterruptedTip": "La última vez, la app se cerró inesperadamente mientras se iniciaba. En este inicio se ha omitido la configuración automática; puedes iniciarla manualmente para reintentarlo.",
"clearData": "Borrar datos",
"textScale": "Escala del texto",
"textScalePreview": "Así se verá el texto en la app",
"followSystem": "Seguir el sistema",
"internet": "Internet",
"systemApp": "Apps del sistema",
"noNetworkApp": "Apps sin red",
"restoreStrategy": "Estrategia de restauración",
"restoreStrategyOverride": "Sobrescribir",
"restoreStrategyCompatible": "Compatible",
"logsTest": "Prueba de registros",
"emptyTip": "{label} no puede estar vacío",
"urlTip": "{label} debe ser una URL",
"invalidLinkTip": "No es un enlace de suscripción ni un enlace para compartir proxies",
"numberTip": "{label} debe ser un número",
"numberRangeTip": "{label} debe estar entre {min} y {max}",
"interval": "Intervalo",
"existsTip": "{label} ya existe",
"maxLengthTip": "{label} debe tener como máximo {max} caracteres",
"singleAdd": "Añadir uno",
"batchAdd": "Añadir en lote",
"batchListInputTip": "Un elemento por línea, o separados por comas",
"batchMapInputTip": "Una entrada por línea: clave, un espacio y el valor",
"singleImport": "Importar uno",
"batchImport": "Importar en lote",
"batchUrlInputTip": "Una URL por línea",
"batchLinkInputTip": "Un enlace por línea",
"batchPreviewTip": "{count} por añadir, {skipped} omitidos por ya existir",
"lineIssueTip": "Línea {line}: {message}",
"singleValueTip": "{label} debe ser un solo elemento",
"deleteTip": "¿Seguro que quieres eliminar este elemento ({label})?",
"deleteMultipTip": "¿Seguro que quieres eliminar los elementos seleccionados ({label})?",
"nullTip": "Aún no hay ningún elemento ({label})",
"script": "Script",
"color": "Color",
"unnamed": "Sin nombre",
"pleaseEnterScriptName": "Introduce un nombre para el script",
"mixedPort": "Puerto mixto",
"socksPort": "Puerto SOCKS",
"redirPort": "Puerto Redir",
"tproxyPort": "Puerto TProxy",
"portTip": "{label} debe estar entre 1024 y 49151",
"portConflictTip": "Introduce un puerto diferente",
"import": "Importar",
"importFile": "Importar desde archivo",
"importUrl": "Importar desde URL",
"startFromScratch": "Empezar desde cero",
"autoSetSystemDns": "Configurar DNS del sistema automáticamente",
"details": "Detalles de {label}",
"creationTime": "Fecha de creación",
"process": "Proceso",
"host": "Host",
"destination": "Destino",
"destinationGeoIP": "GeoIP de destino",
"destinationIPASN": "ASN de la IP de destino",
"specialProxy": "Proxy especial",
"specialRules": "Reglas especiales",
"remoteDestination": "Destino remoto",
"networkType": "Tipo de red",
"proxyChains": "Cadena de proxies",
"log": "Registro",
"connection": "Conexión",
"request": "Solicitud",
"connected": "Conectado",
"disconnected": "Desconectado",
"connecting": "Conectando…",
"restartCoreTip": "¿Seguro que quieres reiniciar el núcleo?",
"forceRestartCoreTip": "¿Seguro que quieres forzar el reinicio del núcleo?",
"dnsHijacking": "Secuestro de DNS",
"coreStatus": "Estado del núcleo",
"appendSystemDns": "Añadir DNS del sistema",
"editRule": "Editar regla",
"extendMode": "Modo de extensión",
"standardModeDesc": "Modo estándar: añade reglas sobre el perfil",
"scriptModeDesc": "Modo script: reescribe todo el perfil con un script de extensión",
"addedRules": "Reglas añadidas",
"controlGlobalAddedRules": "Controlar las reglas añadidas globales",
"quickActions": "Acciones rápidas",
"rulePresetBlockQuic": "Bloquear QUIC",
"rulePresetBlockStun": "Bloquear STUN",
"rulePresetBlockDot": "Bloquear DNS sobre TLS",
"rulePresetBlockLoopback": "Bloquear bucle del proxy",
"rulePresetSystemServicesDirect": "Apple y Microsoft directos",
"rulePresetBittorrentDirect": "BitTorrent directo",
"rulePresetLanDirect": "LAN directa",
"extendScript": "Script de extensión",
"goToConfigureScript": "Ir a la configuración del script",
"goToConfigureAddedRules": "Ir a la configuración de las reglas añadidas",
"editGlobalRules": "Editar reglas globales",
"confirmForceCrashCore": "¿Seguro que quieres forzar el fallo del núcleo?",
"confirmClearAllData": "¿Seguro que quieres borrar todos los datos?",
"loading": "Cargando…",
"yearsAgo": "{count, plural, =1{hace 1 año} other{hace {count} años}}",
"monthsAgo": "{count, plural, =1{hace 1 mes} other{hace {count} meses}}",
"daysAgo": "{count, plural, =1{hace 1 día} other{hace {count} días}}",
"hoursAgo": "{count, plural, =1{hace 1 hora} other{hace {count} horas}}",
"minutesAgo": "{count, plural, =1{hace 1 minuto} other{hace {count} minutos}}",
"justNow": "Ahora mismo",
"yearsAgoShort": "{count, plural, other{hace {count} a}}",
"monthsAgoShort": "{count, plural, other{hace {count} m}}",
"daysAgoShort": "{count, plural, other{hace {count} d}}",
"hoursAgoShort": "{count, plural, other{hace {count} h}}",
"minutesAgoShort": "{count, plural, other{hace {count} min}}",
"noLongerRemind": "No volver a recordármelo",
"accessControlSettings": "Ajustes de control de acceso",
"turnOn": "Activar",
"turnOff": "Desactivar",
"vpnConfigChangeDetected": "Se detectó un cambio en la configuración relacionada con la VPN",
"restart": "Reiniciar",
"speedStatistics": "Estadísticas de velocidad",
"resetPageChangesTip": "Esta página tiene cambios. ¿Seguro que quieres restablecer?",
"customProfile": "Personalizado",
"customProfileDesc": "Crea grupos de proxies y reglas a partir de los proxies de la app y de otros perfiles",
"customProfileResources": "Recursos de perfil personalizado",
"customProfileResourcesDesc": "Los recursos de perfil personalizado incluyen proxies, proveedores de reglas, filtros y conjuntos de iconos.",
"unknownNetworkError": "Error de red desconocido",
"backupFromNewerVersion": "Esta copia de seguridad procede de una versión más reciente de la app. Actualiza la app antes de restaurarla",
"networkTimeoutError": "Se agotó el tiempo de espera de la solicitud. Comprueba tu red o el proxy e inténtalo de nuevo",
"networkHostLookupError": "No se pudo resolver la dirección del servidor. Comprueba que la URL sea correcta y que el DNS funcione",
"networkConnectionError": "No se pudo conectar con el servidor. Comprueba tu conexión de red o los ajustes del proxy",
"networkTlsError": "Falló la conexión segura. Puede que el certificado del servidor no sea válido o que la conexión esté siendo interceptada",
"networkCancelledError": "Se canceló la solicitud",
"networkAccessDeniedError": "El servidor denegó el acceso (HTTP {code}). Puede que el enlace haya caducado o que las credenciales sean incorrectas",
"networkNotFoundError": "No se encontró nada en esta dirección (HTTP {code}). Comprueba que la URL sea correcta",
"networkRateLimitedError": "Demasiadas solicitudes (HTTP 429). Espera un momento e inténtalo de nuevo",
"networkServerError": "El servidor tuvo un problema (HTTP {code}). Inténtalo de nuevo más tarde",
"networkBadResponseError": "El servidor rechazó la solicitud (HTTP {code})",
"networkRequestFailed": "Falló la solicitud de red: {detail}",
"invalidBackupFile": "Archivo de copia de seguridad no válido",
"pruneCache": "Depurar caché",
"backupAndRestore": "Copia de seguridad y restauración",
"restore": "Restaurar",
"restoreSuccess": "Restauración completada",
"restoreOnlyConfig": "Restaurar solo los perfiles",
"restoreAllData": "Restaurar todos los datos",
"addProfile": "Añadir perfil",
"delayTest": "Prueba de latencia",
"proxyGroupEmpty": "El grupo de proxies está vacío",
"confirmExitWindow": "¿Seguro que quieres salir de la ventana actual?",
"optional": "Opcional",
"maxFailedTimes": "Máximo de fallos",
"testInterval": "Intervalo de prueba",
"excludeFilter": "Filtro de exclusión",
"expectedStatus": "Estado esperado",
"strategy": "Estrategia",
"tolerance": "Tolerancia",
"inputProxyGroupName": "Introduce el nombre del grupo de proxies",
"helperCorruptTip": "El servicio auxiliar (Helper) no está disponible; no se puede activar el modo TUN. Reinstala FlClash para restaurarlo.",
"tunAuthorizationFailed": "No se pudo autorizar TUN, por lo que se desactivó.",
"sudoPasswordTitle": "Introduce tu contraseña para sudo",
"coreBlockedByPolicyTip": "Windows se negó a ejecutar FlClashCore.exe (error {code}). Una directiva de control de aplicaciones, como Smart App Control o AppLocker, bloquea los programas sin firmar; permite FlClash en esa directiva o desactívala e inténtalo de nuevo.",
"coreBlockedBySmartAppControlTip": "Smart App Control de Windows bloqueó FlClashCore.exe porque no está firmado. Abre Seguridad de Windows → Control de aplicaciones y navegador → Configuración de Smart App Control, elige Desactivado y vuelve a iniciar FlClash. Smart App Control no se puede volver a activar sin reinstalar Windows.",
"hideFromList": "Ocultar de la lista",
"testWhenUsed": "Probar al usarse",
"disableUDP": "Desactivar UDP",
"confirmDeleteProxyGroup": "¿Seguro que quieres eliminar este grupo de proxies?",
"ruleEmpty": "La regla está vacía",
"inputRuleContent": "Introduce el contenido de la regla",
"ruleSet": "Conjunto de reglas",
"selectRuleSet": "Selecciona un conjunto de reglas",
"splitStrategy": "Estrategia de división",
"selectSplitStrategy": "Selecciona una estrategia de división",
"noResolveHostname": "No resolver nombre de host",
"matchSourceIp": "Coincidir con IP de origen",
"basicInfo": "Información básica",
"additionalParameters": "Parámetros adicionales",
"proxyType": "Tipo de proxy",
"basicStrategy": "Estrategias básicas",
"editProxy": "Editar proxy",
"addNodes": "Añadir nodos",
"addProxyGroup": "Añadir grupo de proxies",
"editProxyGroup": "Editar grupo de proxies",
"iconUrl": "URL del icono",
"iconRecords": "Registros de iconos",
"custom": "Personalizado",
"ruleActionDomainDesc": "Coincide con el dominio completo",
"ruleActionDomainSuffixDesc": "Coincide con un sufijo de dominio",
"ruleActionDomainKeywordDesc": "Coincide con una palabra clave del dominio",
"ruleActionDomainRegexDesc": "Coincide con una expresión regular de dominio",
"ruleActionDomainWildcardDesc": "Coincidencia con comodines; solo se admiten * y ?",
"ruleActionGeositeDesc": "Coincide con los dominios de Geosite",
"ruleActionIpCidrDesc": "Coincide con un rango de direcciones IP",
"ruleActionIpCidr6Desc": "Coincide con un rango de direcciones IP; IP-CIDR6 es solo un alias",
"ruleActionIpSuffixDesc": "Coincide con un rango de sufijo de IP",
"ruleActionIpAsnDesc": "Coincide con el ASN de la IP",
"ruleActionGeoipDesc": "Coincide con el código de país de la IP",
"ruleActionSrcGeoipDesc": "Coincide con el código de país de la IP de origen",
"ruleActionSrcIpAsnDesc": "Coincide con el ASN de la IP de origen",
"ruleActionSrcIpCidrDesc": "Coincide con un rango de direcciones IP de origen",
"ruleActionSrcIpSuffixDesc": "Coincide con un rango de sufijo de IP de origen",
"ruleActionDstPortDesc": "Coincide con el rango de puertos de destino",
"ruleActionSrcPortDesc": "Coincide con el rango de puertos de origen",
"ruleActionInPortDesc": "Coincide con el puerto de entrada",
"ruleActionInTypeDesc": "Coincide con el tipo de entrada",
"ruleActionInUserDesc": "Coincide con el nombre de usuario de entrada; separa varios usuarios con /",
"ruleActionInNameDesc": "Coincide con el nombre de la entrada",
"ruleActionRematchNameDesc": "Coincide con el nombre de rematch; separa varios nombres con /",
"ruleActionProcessPathDesc": "Coincide con la ruta completa del proceso",
"ruleActionProcessPathRegexDesc": "Coincide con una expresión regular de la ruta del proceso",
"ruleActionProcessPathWildcardDesc": "Coincide con comodines en la ruta del proceso; solo se admiten * y ?",
"ruleActionProcessNameDesc": "Coincide con el nombre del proceso; en Android coincide con el nombre del paquete",
"ruleActionProcessNameRegexDesc": "Coincide con una expresión regular del nombre del proceso; en Android coincide con el nombre del paquete",
"ruleActionProcessNameWildcardDesc": "Coincide con comodines en el nombre del proceso; solo se admiten * y ?",
"ruleActionUidDesc": "Coincide con el ID de usuario de Linux",
"ruleActionNetworkDesc": "Coincide con TCP o UDP",
"ruleActionDscpDesc": "Coincide con la marca DSCP (solo entrada UDP de tproxy)",
"ruleActionRuleSetDesc": "Hace referencia a un conjunto de reglas; requiere rule-providers",
"ruleActionAndDesc": "Regla lógica AND",
"ruleActionOrDesc": "Regla lógica OR",
"ruleActionNotDesc": "Regla lógica NOT",
"ruleActionSubRuleDesc": "Coincide con una subregla; cuidado con los paréntesis",
"ruleActionMatchDesc": "Coincide con todas las solicitudes, sin necesidad de condiciones",
"contentNotEmpty": "El contenido no puede estar vacío",
"splitStrategyNotEmpty": "La estrategia de división no puede estar vacía",
"timeout": "Tiempo agotado",
"delayFailed": "Fallido",
"invalidSubRule": "{subRule} no es una SUB_RULE válida",
"invalidPolicy": "{target} no es una política válida",
"invalidProxyProvider": "{providerName} no es un proveedor de proxies válido",
"invalidRuleSet": "{ruleSet} no es un conjunto de reglas válido",
"invalidNetworkContent": "Solo se admite tcp o udp",
"invalidRangeContent": "Introduce números o rangos como 80 u 8000-9000, separados por /",
"invalidDscpContent": "Una marca DSCP no puede superar 63",
"invalidCidrContent": "Introduce un rango de IP en formato CIDR, como 192.168.0.0/16",
"invalidPortRangeContent": "Introduce un puerto o un rango como 443 u 8000-9000",
"invalidHostContent": "Introduce un dominio o una dirección IP",
"invalidListenContent": "Introduce una dirección y un puerto, como 0.0.0.0:1053",
"invalidProxy": "{proxyName} no es un proxy válido",
"locationPermissionRequired": "Se requiere permiso de ubicación",
"locationPermissionGuide": "1. Abre Ajustes del sistema > Privacidad y seguridad\n2. Elige Servicios de localización\n3. Busca {appName} en la lista y márcalo\n\nCuando termines, vuelve a la app para continuar. Gracias por tu colaboración.",
"prerequisites": "Requisitos previos",
"ignoreBatteryOptimization": "Ignorar optimización de batería",
"batteryOptimizationDesc": "Ignorar la optimización de batería mantiene la app en ejecución en segundo plano.",
"locationPermission": "Permiso de ubicación",
"locationPermissionDesc": "El sistema requiere el permiso de ubicación para leer el nombre de la red Wi-Fi. En Android elige “Permitir todo el tiempo”; de lo contrario, no se podrá leer el nombre de la red Wi-Fi mientras la app esté en segundo plano.",
"excludeSsids": "Excluir SSID",
"excludeSsidsDesc": "Al conectarte a una red Wi-Fi con un SSID excluido, el estado de ejecución de la app cambia automáticamente.",
"ssidsEmpty": "No hay SSID",
"onDemand": "Bajo demanda",
"onDemandDesc": "Bajo demanda pausa el proxy en las redes Wi-Fi excluidas.",
"locationPermissionDeniedMessage": "Se denegó el permiso de ubicación, por lo que no se puede leer el nombre de la red Wi-Fi actual. Activa el permiso de ubicación manualmente en los ajustes del sistema.",
"addSsid": "Añadir SSID",
"editSsid": "Editar SSID",
"authorized": "Autorizado",
"tapToAuthorize": "Toca para autorizar",
"suspended": "Suspendido…",
"resourceUpdateInterval": "Intervalo de actualización automática",
"resourceUpdateIntervalTip": "El intervalo de actualización automática debe ser mayor que 0",
"hours": "horas",
"minutes": "minutos",
"geoResources": "Geo",
"geoSkipped": "{name} ya está actualizado",
"geoUpdated": "{name} actualizado",
"secondsCount": "{count, plural, =1{1 segundo} other{{count} segundos}}",
"proxiesCount": "{count, plural, =1{1 proxy} other{{label} proxies}}",
"rulesCount": "{count, plural, =1{1 regla} other{{label} reglas}}",
"proxyGroupsCount": "{count, plural, =1{1 grupo de proxies} other{{label} grupos de proxies}}",
"changelogBreaking": "Cambios incompatibles",
"changelogFeatures": "Novedades",
"changelogFixes": "Correcciones",
"changelogPerformance": "Rendimiento",
"changelogReverts": "Reversiones",
"close": "Cerrar",
"back": "Atrás",
"minimize": "Minimizar",
"maximize": "Maximizar",
"unmaximize": "Restaurar tamaño",
"pinWindow": "Fijar ventana",
"unpinWindow": "Desfijar ventana",
"exitFullScreen": "Salir de pantalla completa",
"torch": "Linterna",
"pickFromAlbum": "Elegir del álbum",
"blockConnection": "Bloquear conexión",
"closeConnections": "Cerrar conexiones",
"scrollToSelected": "Ir a la selección",
"showMore": "Expandir",
"showLess": "Contraer",
"previousMatch": "Coincidencia anterior",
"nextMatch": "Coincidencia siguiente",
"clearSearch": "Borrar búsqueda",
"noSearchResults": "No hay resultados",
"showPassword": "Mostrar contraseña",
"hidePassword": "Ocultar contraseña",
"authorize": "Autorizar",
"installedAppsPermissionRequired": "Se requiere permiso de lista de apps",
"installedAppsPermissionDesc": "Este sistema oculta la lista de apps instaladas hasta que se concede el permiso. Autorízalo para configurar el proxy por app.",
"installedAppsPermissionDeniedMessage": "Se denegó el permiso de lista de apps, por lo que no se pueden mostrar las apps instaladas. Concédelo manualmente en los ajustes del sistema.",
"outboundIp": "IP de salida",
"ipAddress": "Dirección IP",
"ipQualityLevel": "Nivel",
"ipQualityGood": "Buena",
"ipQualityNormal": "Normal",
"ipQualityRisky": "Arriesgada",
"ipType": "Tipo",
"ipTypeResidential": "Residencial",
"ipTypeMobile": "Red móvil",
"ipTypeBusiness": "Empresarial",
"ipTypeHosting": "Centro de datos",
"ipFlags": "Indicadores",
"ipFlagProxy": "Proxy",
"ipFlagVpn": "VPN",
"ipFlagTor": "Tor",
"ipFlagAbuser": "Historial de abusos",
"ipOrganization": "Organización",
"ipAsn": "ASN",
"ipQualitySource": "Respondido por",
"ipQualitySources": "Fuentes",
"ipQualityFailed": "No se pudo determinar el tipo de IP",
"ipQualityRetry": "Comprobar de nuevo",
"ipSourceNoType": "Sin tipo",
"ipSourceRateLimited": "Límite de solicitudes",
"ipSourceIpMismatch": "IP de salida distinta",
"hideIp": "Ocultar IP",
"editorUnavailable": "Editor no disponible",
"replace": "Reemplazar",
"replaceAll": "Reemplazar todo",
"cameraPermissionRequired": "Se requiere permiso de cámara",
"cameraPermissionDesc": "Permite el acceso a la cámara en los ajustes del sistema para escanear códigos QR, o elige una imagen de código QR del álbum.",
"cameraUnavailable": "Cámara no disponible",
"retry": "Reintentar",
"invalidProfileQrcode": "Este código QR no contiene un enlace de perfil",
"dnsQueries": "Consultas DNS",
"recordType": "Tipo de registro",
"answers": "Respuestas",
"responseCode": "Código de respuesta",
"error": "Error",
"initiator": "Origen de la solicitud",
"cache": "Caché",
"yes": "Sí",
"no": "No",
"nonTextProviderFile": "Este recurso externo no es un archivo de texto",
"addCustomProxy": "Añadir proxy",
"definitionNotMap": "La configuración debe ser un mapa YAML con un nombre y un tipo",
"ruleTextInvalid": "La regla debe empezar con un tipo admitido, por ejemplo DOMAIN,example.com,DIRECT",
"ruleListInvalid": "Las reglas deben ser una lista YAML con una regla por elemento, por ejemplo - DOMAIN,example.com,DIRECT",
"shareLinksInvalid": "No se pudo leer ningún proxy de los enlaces",
"shareLinkUnreadable": "No se pudo leer ningún proxy de {link}",
"proxiesProfileLabel": "{name} y otros ({count} proxies)",
"singleShareLinkOnly": "Introduce aquí un solo enlace; para varios, usa la edición rápida de la lista",
"username": "Usuario",
"emptyFallback": "Respaldo si está vacío",
"defaultSelected": "Selección predeterminada",
"hashByInUser": "Hash por usuario de entrada",
"appProxiesEmpty": "No hay proxies locales",
"nodes": "Nodos",
"localProxies": "Proxies locales",
"customIssueEmptyName": "El nombre está vacío",
"customIssueReservedName": "{name} es un nombre de política integrada y no se puede usar aquí",
"customIssueDuplicateName": "El nombre {name} ya lo usa otro proxy o grupo de proxies",
"customIssueCoreRejected": "El núcleo no puede interpretar este proxy: {message}",
"customIssueMissingProxies": "Estos proxies o políticas no existen: {names}",
"customIssueMissingProviders": "Estos proveedores de proxies no existen: {names}",
"customIssueNoProxySource": "No hay proxies ni proveedores de proxies seleccionados, por lo que el núcleo rechaza este grupo",
"customIssueGroupLoop": "Los grupos de proxies se referencian entre sí en bucle: {path}",
"customIssueInvalidEmptyFallback": "El respaldo si está vacío debe ser un proxy, pero {name} no existe o es un grupo de proxies",
"customIssueInvalidFilter": "{name} no es una expresión regular válida: {message}",
"customIssueMissingDialer": "El proxy de marcación {name} no existe",
"customIssueDialerLoop": "{proxy} marca a través de {target}, que lleva de vuelta a {proxy}, por lo que la marcación entra en bucle",
"dialerProxyLoopStopped": "La marcación a través de {name} lleva de vuelta al proxy que lo usa, por lo que se detuvo la conexión",
"customIssuesSummary": "{count} elementos tienen problemas, y aplicar este perfil puede fallar",
"providerInUse": "{label} sigue en uso en {profiles}, que dejarán de funcionar correctamente si se elimina. ¿Seguro que quieres eliminarlo?",
"proxyGroupInUse": "{label} sigue mencionado por reglas, grupos de proxies, proxies de marcación, DNS o NTP de este perfil, que dejarán de funcionar si se elimina. ¿Seguro que quieres eliminarlo?",
"proxiesRemovedTip": "Se eliminarán estos proxies: {removed}. Una línea cuyo nombre cambió cuenta como un proxy nuevo, y los perfiles personalizados que mencionen los eliminados los marcarán como ausentes. ¿Aplicar de todos modos?",
"proxiesReplacedTip": "Se eliminarán estos proxies: {removed}. Se añadirán estos: {added}. Una línea cuyo nombre cambió cuenta como un proxy nuevo, y los perfiles personalizados que mencionen los eliminados los marcarán como ausentes. ¿Aplicar de todos modos?",
"fontSize": "Tamaño",
"large": "Grande",
"extraLarge": "Muy grande",
"switchProfile": "Cambiar de perfil",
"lastUpdated": "Última actualización",
"filters": "Filtros",
"label": "Etiqueta",
"regex": "Expresión regular",
"matchCase": "Coincidir mayúsculas y minúsculas",
"iconSets": "Conjuntos de iconos",
"invalidIconSet": "No es un conjunto de iconos válido",
"recommendedIcons": "Sugeridos",
"localImage": "Imagen local",
"iconSetsEmptyTip": "Aún no hay conjuntos de iconos. Añádelos en Recursos de perfil personalizado → Conjuntos de iconos",
"outbound": "Salida",
"strictRoute": "Ruta estricta",
"congestionController": "Control de congestión TCP",
"congestionControllerDesc": "El control de congestión TCP solo funciona con la pila mips.",
"routeExcludeAddress": "Direcciones excluidas de la ruta",
"keepAliveIdle": "Inactividad de keep-alive TCP",
"disableKeepAlive": "Desactivar keep-alive TCP",
"routingMark": "Marca de enrutamiento",
"strictRouteDesc": "La ruta estricta evita que el tráfico, como el DNS, eluda TUN, y los demás dispositivos ya no pueden acceder a este.",
"initFailed": "Error al iniciar",
"initFailedTip": "FlClash encontró un error al iniciarse y no puede continuar. Copia los detalles para informar del problema. Si sigue ocurriendo, borrar los datos suele permitir que la app vuelva a iniciarse.",
"errorDetails": "Detalles del error",
"stackTrace": "Seguimiento de pila",
"clearDataAndExitTip": "Esto elimina todos los perfiles, ajustes y datos locales, y luego cierra la app. Ábrela de nuevo para empezar de cero.",
"clearDataFailed": "No se pudieron borrar los datos: {detail}",
"bypassPrivateRoute": "Omitir direcciones privadas",
"bypassPrivateRouteDesc": "Con Omitir direcciones privadas activado, las direcciones de LAN, loopback y multidifusión no pasan por TUN.",
"disableIcmpForwarding": "Desactivar reenvío ICMP",
"disableIcmpForwardingDesc": "Con el reenvío ICMP desactivado, TUN responde él mismo al ping, por lo que ping ya no muestra la latencia real.",
"excludeInterface": "Interfaces excluidas",
"excludeInterfaceDesc": "El tráfico que llega por las interfaces excluidas, como docker0, no pasa por TUN.",
"includeInterface": "Interfaces incluidas",
"includeInterfaceDesc": "Solo el tráfico que llega por las interfaces incluidas pasa por TUN, y las interfaces excluidas dejan de aplicarse.",
"includeUid": "ID de usuario incluidos",
"includeUidDesc": "Solo el tráfico de los procesos de los ID de usuario incluidos pasa por TUN.",
"excludeUid": "ID de usuario excluidos",
"excludeUidDesc": "El tráfico de los procesos de los ID de usuario excluidos no pasa por TUN.",
"excludeSrcPort": "Puertos de origen excluidos",
"excludeSrcPortDesc": "El tráfico desde los puertos de origen excluidos no pasa por TUN.",
"excludeDstPort": "Puertos de destino excluidos",
"excludeDstPortDesc": "El tráfico hacia los puertos de destino excluidos no pasa por TUN.",
"udpTimeout": "Tiempo de espera UDP",
"udpTimeoutDesc": "El tiempo de espera UDP se indica en segundos y solo funciona con las pilas gvisor, system y mixed.",
"dnsHijackDesc": "Sin esta entrada, TUN secuestra todas las consultas DNS al puerto 53 (any:53).",
"tunOverrideDesc": "Las entradas añadidas aquí sustituyen los ajustes de TUN de todos los perfiles; lo que no añadas conserva el ajuste del propio perfil. Las entradas que añade un perfil personalizado tienen prioridad.",
"profileTunSettingsDesc": "Se aplica solo a este perfil y tiene prioridad sobre las entradas de sobrescritura de TUN de los ajustes de red.",
"settingsCount": "{count, plural, =1{1 ajuste} other{{count} ajustes}}",
"sectionsInEffect": "{count} en vigor",
"itemsCount": "{count, plural, =1{1 elemento} other{{count} elementos}}",
"additionalPrefix": "Prefijo del nombre",
"additionalSuffix": "Sufijo del nombre",
"skipCertVerify": "Omitir verificación de certificado",
"ipVersion": "Versión de IP",
"addFilters": "Añadir filtros",
"failedItem": "{label}: {message}",
"failedCount": "{count} elementos fallaron",
"importConfigInvalid": "Este archivo no es una configuración importable",
"importConfigReplaceTip": "Al importar se reemplazan los grupos de proxies, las reglas y los ajustes que tiene ahora este perfil",
"networkName": "Nombre de la red",
"networkSecret": "Clave de la red",
"peers": "Pares",
"virtualIpv4": "IPv4 virtual",
"hostname": "Nombre de host",
"exitNodes": "Nodos de salida",
"cipher": "Cifrado",
"protocol": "Protocolo",
"protocolParam": "Parámetro del protocolo",
"obfs": "Ofuscación",
"obfsParam": "Parámetro de ofuscación",
"obfsPassword": "Contraseña de ofuscación",
"obfsMode": "Modo de ofuscación",
"obfsHost": "Host de ofuscación",
"plugin": "Complemento",
"pluginMode": "Modo del complemento",
"pluginHost": "Host del complemento",
"privateKey": "Clave privada",
"publicKey": "Clave pública",
"privateKeyPassphrase": "Frase de contraseña de la clave privada",
"preSharedKey": "Clave precompartida",
"secretKey": "Clave",
"transport": "Transporte",
"uploadBandwidth": "Ancho de banda de subida",
"downloadBandwidth": "Ancho de banda de descarga",
"protocolVersion": "Versión del protocolo",
"portHopping": "Salto de puertos",
"hopInterval": "Intervalo de salto",
"congestionControl": "Control de congestión",
"udpRelayMode": "Modo de retransmisión UDP",
"allowedIps": "IP permitidas",
"remoteDnsResolve": "Resolución DNS remota",
"clientFingerprint": "Huella del cliente",
"required": "Obligatorio",
"ageSecretKey": "Clave secreta de age",
"ageEncryptedProfile": "Perfil cifrado",
"ageSecretKeyRequiredTip": "Este perfil está cifrado con age y necesita una clave secreta de age para descifrarse",
"currentOutboundDesc": "Contiene el botón de inicio y no se puede quitar",
"running": "En ejecución",
"notRunning": "Detenido",
"trafficPeak": "Pico: {value}",
"trafficTotal": "Total: {value}",
"ipQualityShort": "Calidad",
"ipTypeUnknown": "Tipo desconocido",
"tapIconToRetry": "Toca el icono para reintentar",
"networkTakeover": "Control del tráfico",
"portValue": "Puerto {port}",
"serviceAbnormalCount": "{count, plural, =1{1 problema} other{{count} problemas}}",
"serviceAllAvailable": "Todo disponible",
"serviceShownOnDashboard": "Se muestra en el panel",
"groupTimeoutCount": "{count} sin respuesta",
"groupCountUnit": "grupos",
"groupCount": "{count, plural, =1{1 grupo} other{{count} grupos}}",
"proxyGroupsAfterStart": "Los grupos aparecen aquí cuando se inicia el núcleo",
"usedShort": "usado",
"profileUpdatedAt": "Actualizado {time}",
"profileExpiresIn": "Vence el {date}, {days, plural, =1{queda 1 día} other{quedan {days} días}}",
"profileDaysLeft": "{days, plural, =1{Queda 1 día} other{Quedan {days} días}}",
"target": "Destino",
"outboundExit": "Salida",
"duration": "Duración",
"traffic": "Tráfico",
"totalDownload": "Descargado",
"totalUpload": "Subido",
"initiatorShort": "De",
"result": "Resultado",
"elapsed": "Tardó",
"connectionStats": "Estadísticas de conexión",
"connectionStatsDesc": "Conexiones activas, solicitudes recientes y consultas DNS",
"memoryInfoDesc": "Memoria que usan la app y el núcleo",
"editDashboard": "Editar panel",
"done": "Hecho",
"dashboardAddableSection": "Disponibles para añadir",
"customImportDroppedSubRules": "{count, plural, =1{Se descartó 1 regla SUB-RULE, porque los perfiles personalizados no admiten subreglas} other{Se descartaron {label} reglas SUB-RULE, porque los perfiles personalizados no admiten subreglas}}",
"autoInclude": "Incluir automáticamente",
"allReferencedProxies": "Todos los proxies referenciados",
"allReferencedProxyProviders": "Todos los proveedores de proxies referenciados"
}
+943
View File
@@ -0,0 +1,943 @@
{
"rule": "Règle",
"rules": "Règles",
"global": "Global",
"direct": "Direct",
"dashboard": "Tableau de bord",
"proxies": "Proxys",
"profile": "Profil",
"profiles": "Profils",
"tools": "Outils",
"logs": "Journaux",
"resources": "Ressources",
"trafficUsage": "Trafic utilisé",
"networkSpeed": "Débit réseau",
"outboundMode": "Mode sortant",
"serviceStatus": "État des services",
"serviceRestricted": "Accès restreint",
"serviceAvailable": "Disponible",
"serviceUnavailable": "Indisponible",
"serviceFailed": "Échec de la vérification",
"serviceDisallowedIsp": "FAI non autorisé",
"serviceBlocked": "Bloqué",
"serviceUnsupportedRegion": "Région non prise en charge",
"serviceOriginalsOnly": "Contenus originaux uniquement",
"serviceComingSoon": "Bientôt disponible",
"servicePending": "Non vérifié",
"serviceManage": "Gérer les services",
"serviceCheck": "Vérifier",
"serviceCheckAll": "Tout vérifier",
"serviceCheckedAt": "Vérifié à {time}",
"upload": "Envoi",
"download": "Réception",
"usedTraffic": "Trafic utilisé",
"totalTraffic": "Trafic total",
"expireTime": "Date d’expiration",
"nullProfileDesc": "Ajoutez un profil pour commencer",
"settings": "Paramètres",
"language": "Langue",
"defaultText": "Par défaut",
"more": "Plus",
"other": "Autre",
"about": "À propos",
"en": "Anglais",
"es": "Espagnol",
"fr": "Français",
"ja": "Japonais",
"ko": "Coréen",
"ru": "Russe",
"zhCN": "Chinois simplifié",
"theme": "Thème",
"extend": "Extension",
"runtimeConfig": "Configuration d’exécution",
"allowLan": "Autoriser le LAN",
"tun": "TUN",
"tunDesc": "TUN ne fonctionne qu’en mode administrateur.",
"minimizeOnExit": "Réduire à la fermeture",
"autoLaunch": "Lancement automatique",
"silentLaunch": "Lancement silencieux",
"silentLaunchDesc": "Le lancement silencieux démarre l’app sans afficher la fenêtre.",
"autoRun": "Démarrage automatique",
"autoRunDesc": "Le démarrage automatique active le proxy à l’ouverture de l’app.",
"logcat": "Logcat",
"logcatDesc": "Désactiver Logcat masque l’entrée Journaux.",
"autoCheckUpdate": "Rechercher les mises à jour automatiquement",
"checkCertificate": "Vérifier les certificats TLS",
"checkCertificateDesc": "Sans vérification TLS, les abonnements et les sauvegardes sont exposés aux attaques de l’homme du milieu.",
"accessControl": "Contrôle d’accès",
"accessControlDesc": "Le contrôle d’accès choisit les apps qui utilisent le proxy.",
"edit": "Modifier",
"confirm": "Confirmer",
"update": "Mettre à jour",
"add": "Ajouter",
"save": "Enregistrer",
"delete": "Supprimer",
"seconds": "secondes",
"qrcode": "Code QR",
"qrcodeDesc": "Scannez un code QR pour obtenir un profil",
"url": "URL",
"link": "Lien",
"linkDesc": "Obtenir un profil depuis un lien d’abonnement ou un lien de partage de proxy",
"file": "Fichier",
"fileDesc": "Importer directement un fichier de profil",
"name": "Nom",
"profileNameNullValidationDesc": "Veuillez saisir le nom du profil",
"rename": "Renommer",
"profileUrlNullValidationDesc": "Veuillez saisir l’URL du profil",
"profileUrlInvalidValidationDesc": "Veuillez saisir une URL de profil valide",
"autoUpdate": "Mise à jour automatique",
"autoUpdateInterval": "Intervalle de mise à jour automatique",
"profileAutoUpdateIntervalNullValidationDesc": "Veuillez saisir l’intervalle de mise à jour automatique",
"profileAutoUpdateIntervalInvalidValidationDesc": "Veuillez saisir un intervalle valide",
"themeMode": "Mode du thème",
"themeColor": "Couleur du thème",
"preview": "Aperçu",
"auto": "Auto",
"light": "Clair",
"dark": "Sombre",
"importFromLink": "Importer depuis un lien",
"submit": "Valider",
"createProfileFromUrlTip": "Créer un profil à partir de {url} ?",
"create": "Créer",
"pleaseUploadValidQrcode": "Veuillez importer un code QR valide",
"blacklistMode": "Mode liste noire",
"whitelistMode": "Mode liste blanche",
"selectAll": "Tout sélectionner",
"cancelSelectAll": "Tout désélectionner",
"appAccessControl": "Contrôle d’accès des apps",
"accessControlAllowDesc": "Seules les apps sélectionnées passent par le VPN",
"accessControlNotAllowDesc": "Les apps sélectionnées sont exclues du VPN",
"accessControlDisabledDesc": "Le contrôle d’accès des apps est désactivé",
"selected": "Sélectionné",
"port": "Port",
"logLevel": "Niveau de journalisation",
"show": "Afficher",
"exit": "Quitter",
"systemProxy": "Proxy système",
"safeMode": "Mode sans échec",
"safeModeAppTitle": "{appName} (mode sans échec)",
"project": "Projet",
"core": "Cœur",
"tabAnimation": "Animation des onglets",
"desc": "Un client proxy multiplateforme basé sur ClashMeta, simple d’utilisation, open source et sans publicité.",
"startVpn": "Démarrage du VPN…",
"stopVpn": "Arrêt du VPN…",
"localNetworkDeniedTip": "Autorisation du réseau local refusée : la pile gvisor est utilisée, le LAN est inaccessible.",
"compatible": "Mode de compatibilité",
"notSelectedTip": "Le groupe de proxys actuel n’est pas sélectionnable",
"changeProxyFailedTip": "Échec du changement de proxy ; la sélection précédente a été rétablie",
"databaseWriteFailedTip": "Échec de l’enregistrement de la modification ; elle a été annulée",
"tip": "Astuce",
"account": "Compte",
"backup": "Sauvegarde",
"backupSuccess": "Sauvegarde réussie",
"noInfo": "Aucune information",
"pleaseBindWebDAV": "Veuillez lier un WebDAV",
"bind": "Lier",
"connectivity": "Connectivité : ",
"webDAVConfiguration": "Configuration WebDAV",
"address": "Adresse",
"addressHelp": "Adresse du serveur WebDAV",
"addressTip": "Veuillez saisir une adresse WebDAV valide",
"password": "Mot de passe",
"authentication": "Authentification",
"authenticationDesc": "L’authentification demande un nom d’utilisateur et un mot de passe sur le port du proxy local.",
"authenticationSystemProxyDesc": "Le proxy système n’est pas appliqué tant que l’authentification est activée.",
"checkUpdate": "Rechercher des mises à jour",
"discoverNewVersion": "Nouvelle version disponible",
"checkUpdateError": "L’app est déjà à jour",
"goDownload": "Télécharger",
"unknown": "Inconnu",
"country": "Région",
"search": "Rechercher",
"allowBypass": "Autoriser les apps à contourner le VPN",
"externalController": "Contrôleur externe",
"externalControllerDesc": "Le contrôleur externe permet de piloter le cœur via le port 9090.",
"externalControllerSecret": "Secret du contrôleur externe",
"ipv6Desc": "Le trafic IPv6 n’est reçu que si IPv6 est activé.",
"app": "App",
"general": "Général",
"startupAndBackground": "Démarrage et arrière-plan",
"requestsAndUpdates": "Requêtes et mises à jour",
"logsAndDiagnostics": "Journaux et diagnostic",
"userAgent": "User-Agent",
"unifiedDelay": "Délai unifié",
"tcpConcurrent": "TCP simultané",
"geodataLoader": "Mode Geo à faible mémoire",
"requests": "Requêtes",
"recentRequests": "Requêtes récentes",
"findProcessMode": "Recherche de processus",
"init": "Initialisation",
"infiniteTime": "N’expire jamais",
"connections": "Connexions",
"liveConnections": "Connexions en direct",
"inbound": "Entrant",
"intranetIP": "IP du réseau local",
"view": "Afficher",
"cut": "Couper",
"copy": "Copier",
"clipboardWriteFailed": "Impossible de copier dans le presse-papiers. La sélection est peut-être trop volumineuse",
"paste": "Coller",
"testUrl": "URL de test",
"sync": "Synchroniser",
"exclude": "Masquer des tâches récentes",
"compact": "Compact",
"tab": "Onglet",
"list": "Liste",
"delay": "Délai",
"style": "Style",
"size": "Taille",
"sort": "Trier",
"columns": "Colonnes",
"proxyGroup": "Groupe de proxys",
"go": "Aller",
"externalLink": "Lien externe",
"otherContributors": "Autres contributeurs",
"appIconDesign": "Conception de l’icône de l’app",
"autoCloseConnections": "Fermer les connexions automatiquement",
"autoCloseConnectionsDesc": "La fermeture automatique coupe les connexions ouvertes lorsque vous changez de proxy.",
"onlyStatisticsProxy": "Ne compter que le trafic proxy",
"showNotificationStopAction": "Bouton Arrêter dans la notification",
"pureBlackMode": "Mode noir pur",
"sidebarBlur": "Flou de la barre latérale",
"sidebarBlurDesc": "Afficher le bureau flouté derrière la fenêtre à travers la barre latérale",
"liquidGlass": "Liquid Glass",
"keepAliveIntervalDesc": "Intervalle keep-alive TCP",
"entries": " entrées",
"local": "Local",
"remote": "Distant",
"mode": "Mode",
"time": "Heure",
"source": "Source",
"action": "Action",
"intelligentSelected": "Sélection intelligente",
"clipboardImport": "Importer depuis le presse-papiers",
"clipboardExport": "Exporter vers le presse-papiers",
"layout": "Disposition",
"tight": "Serrée",
"standard": "Standard",
"ultra": "Ultra",
"loose": "Aérée",
"profilesSort": "Trier les profils",
"start": "Démarrer",
"runTime": "Durée d’exécution",
"stop": "Arrêter",
"key": "Clé",
"value": "Valeur",
"vpnEnableDesc": "Le VPN fait passer automatiquement tout le trafic du système par VpnService.",
"options": "Options",
"enabled": "Activé",
"disabled": "Désactivé",
"loopback": "Exemption de bouclage UWP",
"providers": "Ressources externes",
"proxyProviders": "Fournisseurs de proxys",
"ruleProviders": "Fournisseurs de règles",
"providerUrlTip": "Seuls les fournisseurs distants sont pris en charge",
"ruleSetEmptyTip": "Le jeu de règles ne contient aucune entrée exploitable",
"ruleSetMixedTip": "Le jeu de règles mélange des domaines et des plages d’IP : son type est indéterminable",
"ruleSetInvalidTip": "Ce fichier mrs est illisible",
"subscriptionInfo": "Infos d’abonnement",
"ntpStatusDesc": "NTP récupère l’heure auprès d’un serveur NTP au lieu de l’horloge système.",
"server": "Serveur",
"ntpInterval": "Intervalle de synchronisation (minutes)",
"dialerProxy": "Proxy de connexion",
"dialerProxyDesc": "Le proxy de connexion est la sortie utilisée pour joindre le serveur NTP.",
"writeToSystem": "Écrire dans le système",
"writeToSystemDesc": "Écrire dans le système règle aussi l’horloge système ; Android l’ignore.",
"sniffer": "Sniffer",
"snifferStatusDesc": "Le sniffer lit le domaine dans le trafic TLS, HTTP et QUIC afin que les règles puissent le reconnaître.",
"overrideDestination": "Remplacer la destination",
"overrideDestinationDesc": "Remplacer la destination se connecte au domaine détecté plutôt qu’à l’adresse d’origine.",
"forceDnsMapping": "Forcer le mappage DNS",
"forceDnsMappingDesc": "Forcer le mappage DNS analyse aussi les connexions dont le domaine provient d’un mappage DNS.",
"parsePureIp": "Analyser les IP seules",
"parsePureIpDesc": "Analyser les IP seules inspecte les connexions qui ne portent qu’une adresse IP.",
"forceDomain": "Domaines forcés",
"forceDomainDesc": "Les domaines forcés sont aussi analysés ; les autres connexions qui ont déjà un domaine sont laissées telles quelles.",
"skipDomain": "Domaines ignorés",
"skipDomainDesc": "Un domaine détecté figurant dans la liste des domaines ignorés n’est pas utilisé.",
"skipSrcAddress": "Adresses sources ignorées",
"skipDstAddress": "Adresses de destination ignorées",
"sniffProtocols": "Protocoles",
"ports": "Ports",
"overrideEntries": "Entrées de remplacement",
"addOverrideEntry": "Ajouter une entrée de remplacement",
"dnsOverrideDesc": "Les entrées ajoutées ici remplacent le DNS de tous les profils URL et fichier ; tout ce qui n’est pas ajouté garde le réglage du profil. Les profils personnalisés définissent leur DNS dans leur contenu.",
"settingEntries": "réglages",
"addSettingEntry": "Ajouter un réglage",
"profileSettingsDesc": "S’applique uniquement à ce profil ; tout ce qui n’est pas ajouté utilise la valeur par défaut.",
"healthCheck": "Contrôle d’état",
"discardChanges": "Abandonner les modifications ?",
"quickEdit": "Modification rapide",
"status": "État",
"statusDesc": "Si DNS est désactivé, le DNS du système est utilisé.",
"respectRules": "Respecter les règles",
"respectRulesDesc": "Respecter les règles fait passer les connexions DNS selon les règles et nécessite un Proxy Server Nameserver.",
"dnsMode": "Mode DNS",
"fakeipRange": "Plage Fake-IP",
"fakeipFilter": "Filtre Fake-IP",
"useHosts": "Utiliser hosts",
"useSystemHosts": "Utiliser les hosts du système",
"fallbackFilter": "Filtre de repli",
"domain": "Domaine",
"listenRoutingMark": "Marque de routage d’écoute",
"listenRoutingMarkDesc": "La marque de routage d’écoute ne fonctionne que sous Linux.",
"ipv6Timeout": "Délai d’expiration IPv6 (ms)",
"cacheAlgorithm": "Algorithme de cache",
"cacheMaxSize": "Taille du cache",
"fakeipRange6": "Plage Fake-IP (IPv6)",
"fakeipFilterMode": "Mode du filtre Fake-IP",
"fakeipFilterModeDesc": "En mode filtre Fake-IP, la liste noire exclut les correspondances, la liste blanche ne falsifie que les correspondances, et « rule » les traite comme des règles.",
"fakeipTtl": "TTL Fake-IP",
"reset": "Réinitialiser",
"actionView": "Afficher/Masquer",
"actionStart": "Démarrer/Arrêter",
"actionMode": "Changer de mode",
"actionProxy": "Proxy système",
"actionTun": "TUN",
"actionRuleMode": "Mode règles",
"actionGlobalMode": "Mode global",
"actionDirectMode": "Mode direct",
"actionDelayTest": "Tester tous les délais",
"actionUpdateProfiles": "Mettre à jour les profils",
"disclaimer": "Avertissement",
"disclaimerDesc": "Avant d’utiliser FlClash (« le Logiciel »), veuillez lire attentivement cette déclaration et vous assurer de l’avoir comprise dans son intégralité. Appuyer sur « Accepter » signifie que vous avez lu, compris et accepté chacune des conditions ci-dessous. Si vous n’êtes pas d’accord, appuyez sur « Quitter » et cessez d’utiliser le Logiciel.",
"disclaimerSoftwareTitle": "Nature du logiciel",
"disclaimerSoftwareContent": "Le Logiciel est un client proxy réseau open source construit sur le cœur Clash.Meta (mihomo). Il se limite à fournir des outils locaux tels que la gestion de la configuration, le routage par règles et le transfert du trafic.",
"disclaimerNoServiceStatement": "Le Logiciel ne fournit lui-même aucun serveur proxy, nœud, abonnement ni service d’accès au réseau, et n’a aucun partenariat, mandat ni lien de garantie avec un quelconque fournisseur de tels services.",
"disclaimerUsageTitle": "Restrictions d’utilisation",
"disclaimerUsageContent": "Le Logiciel est destiné uniquement à des usages non commerciaux tels que l’apprentissage, les échanges et la recherche technique. Toute utilisation à des fins commerciales est strictement interdite, y compris, sans s’y limiter, la distribution payante, la vente groupée, l’intégration à un service commercial ou l’exercice d’une activité au nom du Logiciel. Toute activité commerciale est étrangère au Logiciel et à ses développeurs.\n\nIl est strictement interdit d’utiliser le Logiciel à des fins contraires aux lois et règlements de votre pays ou de votre région, y compris, sans s’y limiter, le contournement de restrictions d’accès au réseau légalement imposées, la diffusion de contenus illicites, le lancement d’attaques réseau ou l’atteinte aux droits légitimes d’autrui.",
"disclaimerResponsibilityTitle": "Votre responsabilité",
"disclaimerResponsibilityContent": "Il vous incombe de vous assurer que l’utilisation du Logiciel est licite là où vous vivez, et vous assumez seul la responsabilité juridique de tout ce que vous faites avec lui et de ses conséquences.\n\nLes abonnements, nœuds et configurations que vous importez relèvent de votre choix. La licéité de leur source, la sécurité de leur contenu et la fiabilité de leur service sont une affaire entre vous et leurs fournisseurs.",
"disclaimerThirdPartyTitle": "Contenus tiers",
"disclaimerThirdPartyContent": "Les liens d’abonnement, fichiers de configuration, jeux de règles, scripts, ressources externes et liens externes sont tous fournis par des tiers. Les développeurs ne peuvent ni ne vont vérifier ou garantir leur légalité, leur exactitude, leur sécurité ou leur disponibilité.\n\nTout vol de données, perte financière, bannissement de compte ou autre dommage causé par un contenu tiers se règle entre vous et le tiers concerné ; les développeurs n’en assument aucune responsabilité.",
"disclaimerWarrantyTitle": "Absence de garantie",
"disclaimerWarrantyContent": "Le Logiciel est fourni « tel quel » et « selon disponibilité », sans garantie d’aucune sorte, expresse ou implicite, y compris, sans s’y limiter, les garanties de qualité marchande, d’adéquation à un usage particulier, d’absence de contrefaçon, de disponibilité ininterrompue, d’absence d’erreurs ou d’absence de failles de sécurité.\n\nLes développeurs ne garantissent pas que le Logiciel répondra à vos besoins ni qu’il fonctionnera sans interruption ni erreur.",
"disclaimerLiabilityTitle": "Limitation de responsabilité",
"disclaimerLiabilityContent": "Dans toute la mesure permise par la loi applicable, ni les développeurs ni aucun contributeur ne sauraient être tenus responsables de dommages directs, indirects, accessoires, spéciaux, punitifs ou consécutifs résultant de l’utilisation ou de l’impossibilité d’utiliser le Logiciel, y compris, sans s’y limiter, la perte de données, les dommages matériels, les pannes réseau, l’interruption d’activité, le manque à gagner ou tout litige qui en découlerait, même s’ils ont été avertis de la possibilité de tels dommages.",
"disclaimerPrivacyTitle": "Collecte de données et confidentialité",
"disclaimerPrivacyContent": "Le Logiciel ne collecte ni ne transmet vos URL d’abonnement, détails de nœuds, contenus de configuration, sites visités, historiques de connexion, contenus de trafic ou journaux. Ces données restent sur votre appareil et les développeurs n’y ont pas accès.\n\nLe Logiciel n’accède au réseau que lorsque vous utilisez une fonction qui en a besoin, par exemple pour récupérer l’URL d’abonnement que vous avez fournie lors de la mise à jour d’un profil, ou pour contacter GitHub lors de la recherche de mises à jour.\n\nLe Logiciel n’inclut aucun service d’analyse ni de rapport de plantage, sur aucune plateforme.",
"disclaimerLicenseTitle": "Licence open source",
"disclaimerLicenseContent": "Le Logiciel est open source sous licence GPL-3.0. Vous pouvez l’utiliser, le modifier et le distribuer librement tant que vous respectez cette licence, qui exige que les œuvres dérivées soient elles aussi publiées sous GPL-3.0 et que les mentions de droit d’auteur d’origine soient conservées.\n\nLes composants tiers du Logiciel, dont le cœur Clash.Meta, suivent leurs propres licences. Les auteurs d’origine ne sont pas responsables des problèmes liés aux versions modifiées ou redistribuées.",
"disclaimerChangesTitle": "Modifications de cette déclaration",
"disclaimerChangesContent": "Les développeurs peuvent réviser cette déclaration à chaque version, et la révision prend effet à la publication de cette version. Continuer à utiliser le Logiciel après une mise à jour signifie que vous acceptez la déclaration révisée.",
"disclaimerAcceptTitle": "Acceptation",
"disclaimerAcceptContent": "En installant, copiant ou utilisant le Logiciel, vous êtes réputé avoir lu et accepté l’intégralité de cette déclaration. Si vous n’êtes pas d’accord avec l’une de ses conditions, cessez immédiatement d’utiliser le Logiciel et désinstallez-le.",
"disclaimerRestateTitle": "Recopier la déclaration",
"disclaimerRestateTip": "Pour confirmer que vous avez compris, saisissez la déclaration suivante en entier dans le champ ci-dessous :",
"disclaimerRestateHint": "Saisissez la déclaration ci-dessus",
"disclaimerRestateMismatch": "Ne correspond pas à la déclaration",
"disclaimerAcknowledgeTitle": "Confirmer la déclaration",
"disclaimerAcknowledgeTip": "Pour confirmer que vous avez compris, lisez la déclaration suivante et cochez la case ci-dessous :",
"disclaimerAcknowledgeLabel": "J'ai lu et compris cette déclaration",
"agree": "Accepter",
"hotkeyManagement": "Gestion des raccourcis",
"hotkeyDesc": "Les raccourcis globaux fonctionnent même lorsque la fenêtre est masquée. Appuyez sur une action pour enregistrer sa combinaison de touches.",
"hotkeyNotSet": "Non défini",
"pressKeyboard": "Appuyez sur une combinaison de touches",
"hotkeyNeedsModifier": "Incluez au moins l’une de ces touches : {modifiers}",
"hotkeyConflictWith": "Déjà utilisé par « {action} ». L’enregistrer le déplace ici.",
"hotkeyUnavailable": "Non enregistré, il est peut-être pris par une autre app",
"remove": "Retirer",
"noNetwork": "Aucun réseau",
"ipv6InboundDesc": "Avec IPv6 activé, le VPN autorise le trafic IPv6 entrant.",
"exportLogs": "Exporter les journaux",
"exportSuccess": "Exportation réussie",
"iconStyle": "Style des icônes",
"iconStyleFilled": "Pleines",
"iconStylePlain": "Simples",
"iconStyleHidden": "Masquées",
"filter": "Filtrer",
"hideTimeoutProxies": "Masquer les proxys expirés",
"hideTimeoutProxiesDesc": "Ignorer les proxys dont le dernier test de délai a expiré",
"stackMode": "Mode de pile",
"network": "Réseau",
"bypassDomain": "Domaines contournés",
"bypassDomainDesc": "Les domaines contournés ne s’appliquent que si le proxy système est activé.",
"resetTip": "Voulez-vous vraiment réinitialiser ?",
"icon": "Icône",
"noData": "Aucune donnée",
"fontFamily": "Police",
"installedFonts": "Polices installées",
"toggle": "Basculer",
"system": "Système",
"routeAddress": "Adresses de route",
"interfaceNameMode": "Interface sortante",
"interfaceNameModeClear": "Aucune",
"interfaceNameModeFollow": "Suivre la configuration",
"interfaceNameModeCustom": "Personnalisée",
"interfaceName": "Nom de l’interface",
"interfaceNameDesc": "Le nom de l’interface est l’interface réseau utilisée par les connexions sortantes.",
"copyEnvVar": "Copier les variables d’environnement",
"memoryInfo": "Infos mémoire",
"total": "Total",
"releaseMemory": "Libérer la mémoire",
"memoryCoreHeapInuse": "Tas utilisé",
"memoryCoreHeapIdle": "Tas inactif",
"memoryCoreStack": "Piles de goroutines",
"memoryCoreRuntime": "Surcharge du runtime",
"memoryCoreNotRunning": "Le cœur n’est pas en cours d’exécution",
"memoryAppResident": "Mémoire résidente",
"memoryAppShared": "App et partagée",
"memoryEstimateDesc": "Estimation d’après la mémoire résidente du processus ; elle peut différer de ce qu’indique le système.",
"memoryEstimateSharedDesc": "Le cœur s’exécute dans le processus de l’app. Sa part est estimée à partir des statistiques du runtime, et le reste compte comme mémoire de l’app et partagée.",
"memoryReleased": "Mémoire libérée",
"memoryReleasedSize": "{size} libérés",
"releaseMemoryFailed": "Échec de la libération de la mémoire",
"cancel": "Annuler",
"fileIsUpdate": "Le fichier a été modifié. Enregistrer les modifications ?",
"profileHasUpdate": "Le profil a été modifié. Désactiver la mise à jour automatique ?",
"hasCacheChange": "Mettre les modifications en cache ?",
"copySuccess": "Copié avec succès",
"copyLink": "Copier le lien",
"exportFile": "Exporter le fichier",
"cacheCorrupt": "Le cache est corrompu. Le vider ?",
"detectionTip": "Repose sur une API tierce ; à titre indicatif uniquement",
"listen": "Écoute",
"undo": "Annuler",
"redo": "Rétablir",
"lineWrap": "Retour à la ligne",
"none": "Aucun",
"advancedConfig": "Configuration avancée",
"advancedConfigDesc": "La configuration avancée regroupe le réseau, le DNS, les règles ajoutées et les scripts.",
"addRule": "Ajouter une règle",
"content": "Contenu",
"ruleTarget": "Cible de la règle",
"matchTarget": "MATCH-TARGET",
"followProfile": "Suivre le profil",
"sourceIp": "IP source",
"noResolve": "Ne pas résoudre l’IP",
"saveChanges": "Enregistrer les modifications ?",
"cannotSave": "Enregistrement impossible",
"navigationBarStyle": "Barre inférieure",
"floating": "Flottante",
"docked": "Ancrée",
"slide": "Glissement",
"fade": "Fondu",
"pureBlack": "Noir pur",
"customColor": "Couleur personnalisée",
"colorStyle": "Style de couleur",
"grid": "Grille",
"spectrum": "Spectre",
"sliders": "Curseurs",
"hue": "Teinte",
"saturation": "Saturation",
"brightness": "Luminosité",
"hex": "Hex",
"monochromeScheme": "Monochrome",
"vividStyle": "Vif",
"tintedStyle": "Teinté",
"softStyle": "Doux",
"developerMode": "Mode développeur",
"developerModeEnableTip": "Le mode développeur est activé.",
"messageTest": "Test de message",
"messageTestTip": "Ceci est un message.",
"crashTest": "Test de plantage",
"crashDetected": "Plantage détecté",
"crashDetectedTip": "L’app n’a pas réussi à finir de démarrer deux fois de suite. Pour sortir de cette boucle, le profil {name} a été désélectionné et la configuration automatique a été ignorée. Vous pouvez le sélectionner de nouveau à tout moment.",
"launchInterrupted": "Démarrage inachevé",
"launchInterruptedTip": "L’app s’est arrêtée de façon inattendue lors de son dernier démarrage. La configuration automatique a été ignorée pour ce lancement ; vous pouvez la démarrer manuellement pour réessayer.",
"clearData": "Effacer les données",
"textScale": "Taille du texte",
"textScalePreview": "Le texte de l’app ressemblera à ceci",
"followSystem": "Suivre le système",
"internet": "Internet",
"systemApp": "Apps système",
"noNetworkApp": "Apps sans réseau",
"restoreStrategy": "Stratégie de restauration",
"restoreStrategyOverride": "Écraser",
"restoreStrategyCompatible": "Compatible",
"logsTest": "Test des journaux",
"emptyTip": "{label} ne peut pas être vide",
"urlTip": "{label} doit être une URL",
"invalidLinkTip": "Ce n’est ni un lien d’abonnement ni un lien de partage de proxy",
"numberTip": "{label} doit être un nombre",
"numberRangeTip": "{label} doit être compris entre {min} et {max}",
"interval": "Intervalle",
"existsTip": "{label} existe déjà",
"maxLengthTip": "{label} ne doit pas dépasser {max} caractères",
"singleAdd": "Ajout unique",
"batchAdd": "Ajout groupé",
"batchListInputTip": "Un élément par ligne, ou séparés par des virgules",
"batchMapInputTip": "Une entrée par ligne : la clé, un espace, puis la valeur",
"singleImport": "Importation unique",
"batchImport": "Importation groupée",
"batchUrlInputTip": "Une URL par ligne",
"batchLinkInputTip": "Un lien par ligne",
"batchPreviewTip": "{count} à ajouter, {skipped} ignorés car déjà présents",
"lineIssueTip": "Ligne {line} : {message}",
"singleValueTip": "{label} doit être un seul élément",
"deleteTip": "Voulez-vous vraiment supprimer cet élément ({label}) ?",
"deleteMultipTip": "Voulez-vous vraiment supprimer les éléments sélectionnés ({label}) ?",
"nullTip": "Aucun élément ({label}) pour le moment",
"script": "Script",
"color": "Couleur",
"unnamed": "Sans nom",
"pleaseEnterScriptName": "Veuillez saisir un nom de script",
"mixedPort": "Port mixte",
"socksPort": "Port SOCKS",
"redirPort": "Port Redir",
"tproxyPort": "Port TProxy",
"portTip": "{label} doit être compris entre 1024 et 49151",
"portConflictTip": "Veuillez saisir un autre port",
"import": "Importer",
"importFile": "Importer depuis un fichier",
"importUrl": "Importer depuis une URL",
"startFromScratch": "Partir de zéro",
"autoSetSystemDns": "Définir automatiquement le DNS du système",
"details": "Détails de {label}",
"creationTime": "Date de création",
"process": "Processus",
"host": "Hôte",
"destination": "Destination",
"destinationGeoIP": "GeoIP de destination",
"destinationIPASN": "ASN de l’IP de destination",
"specialProxy": "Proxy spécial",
"specialRules": "Règles spéciales",
"remoteDestination": "Destination distante",
"networkType": "Type de réseau",
"proxyChains": "Chaîne de proxys",
"log": "Journal",
"connection": "Connexion",
"request": "Requête",
"connected": "Connecté",
"disconnected": "Déconnecté",
"connecting": "Connexion…",
"restartCoreTip": "Voulez-vous vraiment redémarrer le cœur ?",
"forceRestartCoreTip": "Voulez-vous vraiment forcer le redémarrage du cœur ?",
"dnsHijacking": "Détournement DNS",
"coreStatus": "État du cœur",
"appendSystemDns": "Ajouter le DNS du système",
"editRule": "Modifier la règle",
"extendMode": "Mode d’extension",
"standardModeDesc": "Mode standard : ajoute des règles par-dessus le profil",
"scriptModeDesc": "Mode script : réécrit tout le profil avec un script d’extension",
"addedRules": "Règles ajoutées",
"controlGlobalAddedRules": "Gérer les règles ajoutées globales",
"quickActions": "Actions rapides",
"rulePresetBlockQuic": "Bloquer QUIC",
"rulePresetBlockStun": "Bloquer STUN",
"rulePresetBlockDot": "Bloquer DNS over TLS",
"rulePresetBlockLoopback": "Bloquer le bouclage du proxy",
"rulePresetSystemServicesDirect": "Apple et Microsoft en direct",
"rulePresetBittorrentDirect": "BitTorrent en direct",
"rulePresetLanDirect": "LAN en direct",
"extendScript": "Script d’extension",
"goToConfigureScript": "Aller à la configuration du script",
"goToConfigureAddedRules": "Aller à la configuration des règles ajoutées",
"editGlobalRules": "Modifier les règles globales",
"confirmForceCrashCore": "Voulez-vous vraiment forcer le plantage du cœur ?",
"confirmClearAllData": "Voulez-vous vraiment effacer toutes les données ?",
"loading": "Chargement…",
"yearsAgo": "{count, plural, =1{il y a 1 an} other{il y a {count} ans}}",
"monthsAgo": "{count, plural, =1{il y a 1 mois} other{il y a {count} mois}}",
"daysAgo": "{count, plural, =1{il y a 1 jour} other{il y a {count} jours}}",
"hoursAgo": "{count, plural, =1{il y a 1 heure} other{il y a {count} heures}}",
"minutesAgo": "{count, plural, =1{il y a 1 minute} other{il y a {count} minutes}}",
"justNow": "À l’instant",
"yearsAgoShort": "{count, plural, other{il y a {count} a}}",
"monthsAgoShort": "{count, plural, other{il y a {count} m}}",
"daysAgoShort": "{count, plural, other{il y a {count} j}}",
"hoursAgoShort": "{count, plural, other{il y a {count} h}}",
"minutesAgoShort": "{count, plural, other{il y a {count} min}}",
"noLongerRemind": "Ne plus me le rappeler",
"accessControlSettings": "Réglages du contrôle d’accès",
"turnOn": "Activer",
"turnOff": "Désactiver",
"vpnConfigChangeDetected": "Modification de la configuration liée au VPN détectée",
"restart": "Redémarrer",
"speedStatistics": "Statistiques de débit",
"resetPageChangesTip": "Cette page contient des modifications. Voulez-vous vraiment réinitialiser ?",
"customProfile": "Personnalisé",
"customProfileDesc": "Assembler des groupes de proxys et des règles à partir des proxys de l’app et d’autres profils",
"customProfileResources": "Ressources des profils personnalisés",
"customProfileResourcesDesc": "Les ressources des profils personnalisés regroupent des proxys, des fournisseurs de règles, des filtres et des jeux d’icônes.",
"unknownNetworkError": "Erreur réseau inconnue",
"backupFromNewerVersion": "Cette sauvegarde provient d’une version plus récente de l’app. Mettez l’app à jour avant de la restaurer",
"networkTimeoutError": "La requête a expiré. Vérifiez votre réseau ou votre proxy, puis réessayez",
"networkHostLookupError": "Impossible de résoudre l’adresse du serveur. Vérifiez que l’URL est correcte et que le DNS fonctionne",
"networkConnectionError": "Impossible de se connecter au serveur. Vérifiez votre connexion réseau ou vos réglages de proxy",
"networkTlsError": "Échec de la connexion sécurisée. Le certificat du serveur est peut-être invalide, ou la connexion est interceptée",
"networkCancelledError": "La requête a été annulée",
"networkAccessDeniedError": "Le serveur a refusé l’accès (HTTP {code}). Le lien a peut-être expiré, ou les identifiants sont incorrects",
"networkNotFoundError": "Rien n’a été trouvé à cette adresse (HTTP {code}). Vérifiez que l’URL est correcte",
"networkRateLimitedError": "Trop de requêtes (HTTP 429). Patientez un instant puis réessayez",
"networkServerError": "Le serveur a rencontré un problème (HTTP {code}). Réessayez plus tard",
"networkBadResponseError": "Le serveur a rejeté la requête (HTTP {code})",
"networkRequestFailed": "Échec de la requête réseau : {detail}",
"invalidBackupFile": "Fichier de sauvegarde non valide",
"pruneCache": "Nettoyer le cache",
"backupAndRestore": "Sauvegarde et restauration",
"restore": "Restaurer",
"restoreSuccess": "Restauration réussie",
"restoreOnlyConfig": "Restaurer uniquement les profils",
"restoreAllData": "Restaurer toutes les données",
"addProfile": "Ajouter un profil",
"delayTest": "Test de délai",
"proxyGroupEmpty": "Le groupe de proxys est vide",
"confirmExitWindow": "Voulez-vous vraiment quitter la fenêtre actuelle ?",
"optional": "Facultatif",
"maxFailedTimes": "Échecs maximum",
"testInterval": "Intervalle de test",
"excludeFilter": "Filtre d’exclusion",
"expectedStatus": "Statut attendu",
"strategy": "Stratégie",
"tolerance": "Tolérance",
"inputProxyGroupName": "Saisissez le nom du groupe de proxys",
"helperCorruptTip": "Service Helper indisponible ; le mode TUN ne peut pas être activé. Réinstallez FlClash pour le rétablir.",
"tunAuthorizationFailed": "TUN n’a pas pu être autorisé et a donc été désactivé.",
"sudoPasswordTitle": "Saisissez votre mot de passe pour sudo",
"coreBlockedByPolicyTip": "Windows a refusé d’exécuter FlClashCore.exe (erreur {code}). Une stratégie de contrôle des applications, telle que Smart App Control ou AppLocker, bloque les programmes non signés ; autorisez FlClash dans cette stratégie ou désactivez-la, puis réessayez.",
"coreBlockedBySmartAppControlTip": "Windows Smart App Control a bloqué FlClashCore.exe car il n’est pas signé. Ouvrez Sécurité Windows → Contrôle des applications et du navigateur → Paramètres de Smart App Control, choisissez Désactivé, puis relancez FlClash. Smart App Control ne peut pas être réactivé sans réinstaller Windows.",
"hideFromList": "Masquer de la liste",
"testWhenUsed": "Tester à l’utilisation",
"disableUDP": "Désactiver UDP",
"confirmDeleteProxyGroup": "Voulez-vous vraiment supprimer ce groupe de proxys ?",
"ruleEmpty": "La règle est vide",
"inputRuleContent": "Saisissez le contenu de la règle",
"ruleSet": "Jeu de règles",
"selectRuleSet": "Veuillez sélectionner un jeu de règles",
"splitStrategy": "Stratégie de répartition",
"selectSplitStrategy": "Veuillez sélectionner une stratégie de répartition",
"noResolveHostname": "Ne pas résoudre le nom d’hôte",
"matchSourceIp": "Faire correspondre l’IP source",
"basicInfo": "Infos de base",
"additionalParameters": "Paramètres supplémentaires",
"proxyType": "Type de proxy",
"basicStrategy": "Stratégies de base",
"editProxy": "Modifier le proxy",
"addNodes": "Ajouter des nœuds",
"addProxyGroup": "Ajouter un groupe de proxys",
"editProxyGroup": "Modifier le groupe de proxys",
"iconUrl": "URL de l’icône",
"iconRecords": "Historique des icônes",
"custom": "Personnalisé",
"ruleActionDomainDesc": "Correspond au domaine complet",
"ruleActionDomainSuffixDesc": "Correspond à un suffixe de domaine",
"ruleActionDomainKeywordDesc": "Correspond à un mot-clé de domaine",
"ruleActionDomainRegexDesc": "Correspond à une regex de domaine",
"ruleActionDomainWildcardDesc": "Correspondance par caractères génériques ; seuls * et ? sont pris en charge",
"ruleActionGeositeDesc": "Correspond aux domaines de Geosite",
"ruleActionIpCidrDesc": "Correspond à une plage d’adresses IP",
"ruleActionIpCidr6Desc": "Correspond à une plage d’adresses IP ; IP-CIDR6 n’est qu’un alias",
"ruleActionIpSuffixDesc": "Correspond à une plage de suffixes d’IP",
"ruleActionIpAsnDesc": "Correspond à l’ASN de l’IP",
"ruleActionGeoipDesc": "Correspond au code pays de l’IP",
"ruleActionSrcGeoipDesc": "Correspond au code pays de l’IP source",
"ruleActionSrcIpAsnDesc": "Correspond à l’ASN de l’IP source",
"ruleActionSrcIpCidrDesc": "Correspond à une plage d’adresses IP source",
"ruleActionSrcIpSuffixDesc": "Correspond à une plage de suffixes d’IP source",
"ruleActionDstPortDesc": "Correspond à la plage de ports de destination",
"ruleActionSrcPortDesc": "Correspond à la plage de ports source",
"ruleActionInPortDesc": "Correspond au port entrant",
"ruleActionInTypeDesc": "Correspond au type entrant",
"ruleActionInUserDesc": "Correspond au nom d’utilisateur entrant ; séparez plusieurs noms par /",
"ruleActionInNameDesc": "Correspond au nom entrant",
"ruleActionRematchNameDesc": "Correspond au nom de rematch ; séparez plusieurs noms par /",
"ruleActionProcessPathDesc": "Correspond au chemin complet du processus",
"ruleActionProcessPathRegexDesc": "Correspond par regex au chemin du processus",
"ruleActionProcessPathWildcardDesc": "Correspond par caractères génériques au chemin du processus ; seuls * et ? sont pris en charge",
"ruleActionProcessNameDesc": "Correspond au nom du processus ; correspond au nom du paquet sur Android",
"ruleActionProcessNameRegexDesc": "Correspond par regex au nom du processus ; correspond au nom du paquet sur Android",
"ruleActionProcessNameWildcardDesc": "Correspond par caractères génériques au nom du processus ; seuls * et ? sont pris en charge",
"ruleActionUidDesc": "Correspond à l’ID d’utilisateur Linux",
"ruleActionNetworkDesc": "Correspond à TCP ou UDP",
"ruleActionDscpDesc": "Correspond à la marque DSCP (entrant UDP tproxy uniquement)",
"ruleActionRuleSetDesc": "Référence un jeu de règles ; nécessite rule-providers",
"ruleActionAndDesc": "Règle logique ET",
"ruleActionOrDesc": "Règle logique OU",
"ruleActionNotDesc": "Règle logique NON",
"ruleActionSubRuleDesc": "Correspond vers une sous-règle ; attention aux parenthèses",
"ruleActionMatchDesc": "Correspond à toutes les requêtes, sans condition",
"contentNotEmpty": "Le contenu ne peut pas être vide",
"splitStrategyNotEmpty": "La stratégie de répartition ne peut pas être vide",
"timeout": "Expiré",
"delayFailed": "Échec",
"invalidSubRule": "{subRule} n’est pas un SUB_RULE valide",
"invalidPolicy": "{target} n’est pas une stratégie valide",
"invalidProxyProvider": "{providerName} n’est pas un fournisseur de proxys valide",
"invalidRuleSet": "{ruleSet} n’est pas un jeu de règles valide",
"invalidNetworkContent": "Seuls tcp ou udp sont pris en charge",
"invalidRangeContent": "Saisissez des nombres ou des plages tels que 80 ou 8000-9000, séparés par /",
"invalidDscpContent": "Une marque DSCP ne peut pas dépasser 63",
"invalidCidrContent": "Saisissez une plage d’IP au format CIDR, par exemple 192.168.0.0/16",
"invalidPortRangeContent": "Saisissez un port ou une plage, par exemple 443 ou 8000-9000",
"invalidHostContent": "Saisissez un domaine ou une adresse IP",
"invalidListenContent": "Saisissez une adresse et un port, par exemple 0.0.0.0:1053",
"invalidProxy": "{proxyName} n’est pas un proxy valide",
"locationPermissionRequired": "Autorisation de localisation requise",
"locationPermissionGuide": "1. Ouvrez Réglages système > Confidentialité et sécurité\n2. Choisissez Service de localisation\n3. Trouvez {appName} dans la liste et cochez-le\n\nUne fois terminé, revenez dans l’app pour continuer. Merci de votre coopération.",
"prerequisites": "Prérequis",
"ignoreBatteryOptimization": "Ignorer l’optimisation de la batterie",
"batteryOptimizationDesc": "Ignorer l’optimisation de la batterie maintient l’app active en arrière-plan.",
"locationPermission": "Autorisation de localisation",
"locationPermissionDesc": "Le système exige l’autorisation de localisation pour lire le nom du Wi-Fi. Sur Android, choisissez « Toujours autoriser », sinon le nom du Wi-Fi ne peut pas être lu lorsque l’app est en arrière-plan.",
"excludeSsids": "SSID exclus",
"excludeSsidsDesc": "Lorsque vous êtes connecté à un Wi-Fi dont le SSID est exclu, l’état d’exécution de l’app change automatiquement.",
"ssidsEmpty": "Les SSID sont vides",
"onDemand": "À la demande",
"onDemandDesc": "À la demande met le proxy en pause sur les Wi-Fi exclus.",
"locationPermissionDeniedMessage": "L’autorisation de localisation a été refusée ; le nom du Wi-Fi actuel ne peut donc pas être lu. Veuillez l’activer manuellement dans les réglages du système.",
"addSsid": "Ajouter un SSID",
"editSsid": "Modifier le SSID",
"authorized": "Autorisé",
"tapToAuthorize": "Appuyez pour autoriser",
"suspended": "Suspendu…",
"resourceUpdateInterval": "Intervalle de mise à jour automatique",
"resourceUpdateIntervalTip": "L’intervalle de mise à jour automatique doit être supérieur à 0",
"hours": "heures",
"minutes": "minutes",
"geoResources": "Geo",
"geoSkipped": "{name} est déjà à jour",
"geoUpdated": "{name} mis à jour",
"secondsCount": "{count, plural, =1{1 seconde} other{{count} secondes}}",
"proxiesCount": "{count, plural, =1{1 proxy} other{{label} proxys}}",
"rulesCount": "{count, plural, =1{1 règle} other{{label} règles}}",
"proxyGroupsCount": "{count, plural, =1{1 groupe de proxys} other{{label} groupes de proxys}}",
"changelogBreaking": "Changements majeurs",
"changelogFeatures": "Nouveautés",
"changelogFixes": "Corrections de bugs",
"changelogPerformance": "Performances",
"changelogReverts": "Annulations",
"close": "Fermer",
"back": "Retour",
"minimize": "Réduire",
"maximize": "Agrandir",
"unmaximize": "Restaurer",
"pinWindow": "Épingler la fenêtre",
"unpinWindow": "Désépingler la fenêtre",
"exitFullScreen": "Quitter le plein écran",
"torch": "Lampe torche",
"pickFromAlbum": "Choisir dans l’album",
"blockConnection": "Bloquer la connexion",
"closeConnections": "Fermer les connexions",
"scrollToSelected": "Défiler jusqu’à la sélection",
"showMore": "Développer",
"showLess": "Réduire",
"previousMatch": "Résultat précédent",
"nextMatch": "Résultat suivant",
"clearSearch": "Effacer la recherche",
"noSearchResults": "Aucun résultat",
"showPassword": "Afficher le mot de passe",
"hidePassword": "Masquer le mot de passe",
"authorize": "Autoriser",
"installedAppsPermissionRequired": "Autorisation de la liste des apps requise",
"installedAppsPermissionDesc": "Ce système masque la liste des apps installées tant que l’autorisation n’est pas accordée. Accordez-la pour configurer le proxy par app.",
"installedAppsPermissionDeniedMessage": "L’autorisation de la liste des apps a été refusée ; les apps installées ne peuvent donc pas être listées. Veuillez l’accorder manuellement dans les réglages du système.",
"outboundIp": "IP sortante",
"ipAddress": "Adresse IP",
"ipQualityLevel": "Niveau",
"ipQualityGood": "Bonne",
"ipQualityNormal": "Normale",
"ipQualityRisky": "À risque",
"ipType": "Type",
"ipTypeResidential": "Résidentielle",
"ipTypeMobile": "Réseau mobile",
"ipTypeBusiness": "Entreprise",
"ipTypeHosting": "Centre de données",
"ipFlags": "Indicateurs",
"ipFlagProxy": "Proxy",
"ipFlagVpn": "VPN",
"ipFlagTor": "Tor",
"ipFlagAbuser": "Antécédents d’abus",
"ipOrganization": "Organisation",
"ipAsn": "ASN",
"ipQualitySource": "Réponse de",
"ipQualitySources": "Sources",
"ipQualityFailed": "Impossible de déterminer le type de l’IP",
"ipQualityRetry": "Vérifier à nouveau",
"ipSourceNoType": "Aucun type",
"ipSourceRateLimited": "Limite de débit atteinte",
"ipSourceIpMismatch": "IP sortante différente",
"hideIp": "Masquer l’IP",
"editorUnavailable": "Éditeur indisponible",
"replace": "Remplacer",
"replaceAll": "Tout remplacer",
"cameraPermissionRequired": "Autorisation de l’appareil photo requise",
"cameraPermissionDesc": "Autorisez l’accès à l’appareil photo dans les réglages du système pour scanner des codes QR, ou choisissez une image de code QR dans l’album.",
"cameraUnavailable": "Appareil photo indisponible",
"retry": "Réessayer",
"invalidProfileQrcode": "Ce code QR ne contient pas de lien de profil",
"dnsQueries": "Requêtes DNS",
"recordType": "Type d’enregistrement",
"answers": "Réponses",
"responseCode": "Code de réponse",
"error": "Erreur",
"initiator": "Initiateur",
"cache": "Cache",
"yes": "Oui",
"no": "Non",
"nonTextProviderFile": "Cette ressource externe n’est pas un fichier texte",
"addCustomProxy": "Ajouter un proxy",
"definitionNotMap": "La configuration doit être un mappage YAML avec un nom et un type",
"ruleTextInvalid": "La règle doit commencer par un type pris en charge, par exemple DOMAIN,example.com,DIRECT",
"ruleListInvalid": "Les règles doivent former une liste YAML avec une règle par élément, par exemple - DOMAIN,example.com,DIRECT",
"shareLinksInvalid": "Aucun proxy n’a pu être lu dans les liens",
"shareLinkUnreadable": "Aucun proxy n’a pu être lu dans {link}",
"proxiesProfileLabel": "{name} et autres ({count} proxys)",
"singleShareLinkOnly": "Saisissez un seul lien ici ; pour en modifier plusieurs, utilisez la modification rapide de la liste",
"username": "Nom d’utilisateur",
"emptyFallback": "Repli si vide",
"defaultSelected": "Sélection par défaut",
"hashByInUser": "Hachage par utilisateur entrant",
"appProxiesEmpty": "Aucun proxy local",
"nodes": "Nœuds",
"localProxies": "Proxys locaux",
"customIssueEmptyName": "Le nom est vide",
"customIssueReservedName": "{name} est un nom de stratégie intégré et ne peut pas être utilisé ici",
"customIssueDuplicateName": "Le nom {name} est déjà utilisé par un autre proxy ou groupe de proxys",
"customIssueCoreRejected": "Le cœur ne peut pas analyser ce proxy : {message}",
"customIssueMissingProxies": "Ces proxys ou stratégies n’existent pas : {names}",
"customIssueMissingProviders": "Ces fournisseurs de proxys n’existent pas : {names}",
"customIssueNoProxySource": "Aucun proxy ni fournisseur de proxys n’est sélectionné ; le cœur rejette donc ce groupe",
"customIssueGroupLoop": "Les groupes de proxys se référencent mutuellement en boucle : {path}",
"customIssueInvalidEmptyFallback": "Le repli si vide doit être un proxy, mais {name} n’existe pas ou est un groupe de proxys",
"customIssueInvalidFilter": "{name} n’est pas une regex valide : {message}",
"customIssueMissingDialer": "Le proxy de connexion {name} n’existe pas",
"customIssueDialerLoop": "{proxy} se connecte via {target}, qui renvoie à {proxy} ; la connexion boucle",
"dialerProxyLoopStopped": "La connexion via {name} renvoie au proxy qui l’utilise ; la connexion a donc été arrêtée",
"customIssuesSummary": "{count} éléments posent problème, et l’application de ce profil peut échouer",
"providerInUse": "{label} est encore utilisé par {profiles}, qui ne fonctionneront plus correctement s’il est supprimé. Voulez-vous vraiment le supprimer ?",
"proxyGroupInUse": "{label} est encore nommé par des règles, des groupes de proxys, des proxys de connexion, le DNS ou le NTP de ce profil, qui cesseront de fonctionner s’il est supprimé. Voulez-vous vraiment le supprimer ?",
"proxiesRemovedTip": "Ces proxys seront supprimés : {removed}. Une ligne dont le nom a changé compte comme un nouveau proxy, et les profils personnalisés qui nomment les proxys supprimés les signaleront comme manquants. Appliquer quand même ?",
"proxiesReplacedTip": "Ces proxys seront supprimés : {removed}. Ceux-ci seront ajoutés : {added}. Une ligne dont le nom a changé compte comme un nouveau proxy, et les profils personnalisés qui nomment les proxys supprimés les signaleront comme manquants. Appliquer quand même ?",
"fontSize": "Taille",
"large": "Grande",
"extraLarge": "Très grande",
"switchProfile": "Changer de profil",
"lastUpdated": "Dernière mise à jour",
"filters": "Filtres",
"label": "Libellé",
"regex": "Regex",
"matchCase": "Respecter la casse",
"iconSets": "Jeux d’icônes",
"invalidIconSet": "Jeu d’icônes non valide",
"recommendedIcons": "Suggestions",
"localImage": "Image locale",
"iconSetsEmptyTip": "Aucun jeu d’icônes pour le moment. Ajoutez-en dans Ressources des profils personnalisés → Jeux d’icônes",
"outbound": "Sortant",
"strictRoute": "Route stricte",
"congestionController": "Contrôle de congestion TCP",
"congestionControllerDesc": "Le contrôle de congestion TCP ne fonctionne qu’avec la pile mips.",
"routeExcludeAddress": "Adresses de route exclues",
"keepAliveIdle": "Inactivité keep-alive TCP",
"disableKeepAlive": "Désactiver le keep-alive TCP",
"routingMark": "Marque de routage",
"strictRouteDesc": "La route stricte empêche le trafic tel que le DNS de contourner TUN, et les autres appareils ne peuvent plus joindre celui-ci.",
"initFailed": "Échec du démarrage",
"initFailedTip": "FlClash a rencontré une erreur au démarrage et ne peut pas continuer. Copiez les détails pour signaler le problème. Si cela se reproduit, effacer les données permet généralement de relancer l’app.",
"errorDetails": "Détails de l’erreur",
"stackTrace": "Trace de pile",
"clearDataAndExitTip": "Cela supprime tous les profils, réglages et données locales, puis ferme l’app. Rouvrez-la pour repartir de zéro.",
"clearDataFailed": "Impossible d’effacer les données : {detail}",
"bypassPrivateRoute": "Contourner les adresses privées",
"bypassPrivateRouteDesc": "Avec « Contourner les adresses privées » activé, les adresses LAN, de bouclage et multicast ne passent pas par TUN.",
"disableIcmpForwarding": "Désactiver le transfert ICMP",
"disableIcmpForwardingDesc": "Avec le transfert ICMP désactivé, TUN répond lui-même au ping, qui n’indique donc plus la latence réelle.",
"excludeInterface": "Interfaces exclues",
"excludeInterfaceDesc": "Le trafic qui arrive sur les interfaces exclues, par exemple docker0, ne passe pas par TUN.",
"includeInterface": "Interfaces incluses",
"includeInterfaceDesc": "Seul le trafic arrivant sur les interfaces incluses passe par TUN, et les interfaces exclues ne s’appliquent plus.",
"includeUid": "ID utilisateur inclus",
"includeUidDesc": "Seul le trafic des processus des ID utilisateur inclus passe par TUN.",
"excludeUid": "ID utilisateur exclus",
"excludeUidDesc": "Le trafic des processus des ID utilisateur exclus ne passe pas par TUN.",
"excludeSrcPort": "Ports source exclus",
"excludeSrcPortDesc": "Le trafic issu des ports source exclus ne passe pas par TUN.",
"excludeDstPort": "Ports de destination exclus",
"excludeDstPortDesc": "Le trafic vers les ports de destination exclus ne passe pas par TUN.",
"udpTimeout": "Délai UDP",
"udpTimeoutDesc": "Le délai UDP est en secondes et ne fonctionne qu’avec les piles gvisor, system et mixed.",
"dnsHijackDesc": "Sans cette entrée, TUN détourne toutes les requêtes DNS vers le port 53 (any:53).",
"tunOverrideDesc": "Les entrées ajoutées ici remplacent les réglages TUN de tous les profils ; tout ce qui n’est pas ajouté garde le réglage du profil. Les entrées qu’un profil personnalisé ajoute lui-même priment.",
"profileTunSettingsDesc": "S’applique uniquement à ce profil et prime sur les entrées de remplacement TUN des réglages réseau.",
"settingsCount": "{count, plural, =1{1 réglage} other{{count} réglages}}",
"sectionsInEffect": "{count} actifs",
"itemsCount": "{count, plural, =1{1 élément} other{{count} éléments}}",
"additionalPrefix": "Préfixe du nom",
"additionalSuffix": "Suffixe du nom",
"skipCertVerify": "Ignorer la vérification du certificat",
"ipVersion": "Version d’IP",
"addFilters": "Ajouter des filtres",
"failedItem": "{label} : {message}",
"failedCount": "{count} éléments en échec",
"importConfigInvalid": "Ce fichier n’est pas une configuration importable",
"importConfigReplaceTip": "L’importation remplace les groupes de proxys, les règles et les réglages actuels de ce profil",
"networkName": "Nom du réseau",
"networkSecret": "Secret du réseau",
"peers": "Pairs",
"virtualIpv4": "IPv4 virtuelle",
"hostname": "Nom d’hôte",
"exitNodes": "Nœuds de sortie",
"cipher": "Chiffrement",
"protocol": "Protocole",
"protocolParam": "Paramètre du protocole",
"obfs": "Obfuscation",
"obfsParam": "Paramètre d’obfuscation",
"obfsPassword": "Mot de passe d’obfuscation",
"obfsMode": "Mode d’obfuscation",
"obfsHost": "Hôte d’obfuscation",
"plugin": "Plugin",
"pluginMode": "Mode du plugin",
"pluginHost": "Hôte du plugin",
"privateKey": "Clé privée",
"publicKey": "Clé publique",
"privateKeyPassphrase": "Phrase secrète de la clé privée",
"preSharedKey": "Clé prépartagée",
"secretKey": "Clé",
"transport": "Transport",
"uploadBandwidth": "Bande passante d’envoi",
"downloadBandwidth": "Bande passante de réception",
"protocolVersion": "Version du protocole",
"portHopping": "Saut de port",
"hopInterval": "Intervalle de saut",
"congestionControl": "Contrôle de congestion",
"udpRelayMode": "Mode de relais UDP",
"allowedIps": "IP autorisées",
"remoteDnsResolve": "Résolution DNS distante",
"clientFingerprint": "Empreinte client",
"required": "Obligatoire",
"ageSecretKey": "Clé secrète age",
"ageEncryptedProfile": "Profil chiffré",
"ageSecretKeyRequiredTip": "Ce profil est chiffré avec age et nécessite une clé secrète age pour être déchiffré",
"currentOutboundDesc": "Contient le bouton de démarrage et ne peut pas être retiré",
"running": "En cours d’exécution",
"notRunning": "À l’arrêt",
"trafficPeak": "Pic {value}",
"trafficTotal": "Total {value}",
"ipQualityShort": "Qualité",
"ipTypeUnknown": "Type inconnu",
"tapIconToRetry": "Appuyez sur l’icône pour réessayer",
"networkTakeover": "Prise en charge du trafic",
"portValue": "Port {port}",
"serviceAbnormalCount": "{count, plural, =1{1 problème} other{{count} problèmes}}",
"serviceAllAvailable": "Tout est disponible",
"serviceShownOnDashboard": "Affiché sur le tableau de bord",
"groupTimeoutCount": "{count} expirés",
"groupCountUnit": "groupes",
"groupCount": "{count, plural, =1{1 groupe} other{{count} groupes}}",
"proxyGroupsAfterStart": "Les groupes s’affichent ici une fois le cœur démarré",
"usedShort": "utilisé",
"profileUpdatedAt": "Mis à jour {time}",
"profileExpiresIn": "Expire le {date}, {days, plural, =1{1 jour restant} other{{days} jours restants}}",
"profileDaysLeft": "{days, plural, =1{1 jour restant} other{{days} jours restants}}",
"target": "Cible",
"outboundExit": "Sortie",
"duration": "Durée",
"traffic": "Trafic",
"totalDownload": "Reçu",
"totalUpload": "Envoyé",
"initiatorShort": "De",
"result": "Résultat",
"elapsed": "Durée",
"connectionStats": "Statistiques de connexion",
"connectionStatsDesc": "Connexions en direct, requêtes récentes et requêtes DNS",
"memoryInfoDesc": "Mémoire utilisée par l’app et le cœur",
"editDashboard": "Modifier le tableau de bord",
"done": "Terminé",
"dashboardAddableSection": "Disponibles à l’ajout",
"customImportDroppedSubRules": "{count, plural, =1{1 règle SUB-RULE a été abandonnée, car les profils personnalisés ne prennent pas en charge les sous-règles} other{{label} règles SUB-RULE ont été abandonnées, car les profils personnalisés ne prennent pas en charge les sous-règles}}",
"autoInclude": "Inclure automatiquement",
"allReferencedProxies": "Tous les proxys référencés",
"allReferencedProxyProviders": "Tous les fournisseurs de proxys référencés"
}
+89 -55
View File
@@ -13,7 +13,6 @@
"trafficUsage": "トラフィック統計",
"networkSpeed": "ネットワーク速度",
"outboundMode": "アウトバウンドモード",
"networkDetection": "ネットワーク検出",
"serviceStatus": "サービスの状態",
"serviceRestricted": "アクセス制限",
"serviceAvailable": "利用可能",
@@ -42,13 +41,15 @@
"other": "その他",
"about": "アプリについて",
"en": "英語",
"es": "スペイン語",
"fr": "フランス語",
"ja": "日本語",
"ko": "韓国語",
"ru": "ロシア語",
"zhCN": "簡体字中国語",
"theme": "テーマ",
"themeDesc": "ダークモードの設定と色の調整",
"extend": "拡張",
"finalConfig": "最終設定",
"runtimeConfig": "ランタイム設定",
"allowLan": "LANプロキシ",
"tun": "TUN",
"tunDesc": "TUN は管理者モードでのみ有効です。",
@@ -64,7 +65,7 @@
"checkCertificate": "TLS証明書を検証",
"checkCertificateDesc": "TLS 証明書の検証を無効にすると、サブスクリプションとバックアップが中間者攻撃にさらされます。",
"accessControl": "アクセス制御",
"accessControlDesc": "プロキシを利用するアプリを設定します",
"accessControlDesc": "アクセス制御では、プロキシを利用するアプリを選びます。",
"edit": "編集",
"confirm": "OK",
"update": "更新",
@@ -85,7 +86,7 @@
"profileUrlNullValidationDesc": "プロファイルのURLを入力してください",
"profileUrlInvalidValidationDesc": "有効なプロファイルURLを入力してください",
"autoUpdate": "自動更新",
"autoUpdateInterval": "自動更新間隔(分)",
"autoUpdateInterval": "自動更新間隔",
"profileAutoUpdateIntervalNullValidationDesc": "自動更新間隔を入力してください",
"profileAutoUpdateIntervalInvalidValidationDesc": "有効な間隔を入力してください",
"themeMode": "テーマモード",
@@ -180,9 +181,7 @@
"testUrl": "テストURL",
"sync": "同期",
"exclude": "最近のタスクから隠す",
"expand": "標準",
"shrink": "コンパクト",
"min": "最小",
"compact": "コンパクト",
"tab": "タブ",
"list": "リスト",
"delay": "遅延",
@@ -202,6 +201,7 @@
"pureBlackMode": "ピュアブラックモード",
"sidebarBlur": "サイドバーのぼかし",
"sidebarBlurDesc": "ウィンドウ背後のデスクトップをぼかしてサイドバーに透過します",
"liquidGlass": "リキッドグラス",
"keepAliveIntervalDesc": "TCPキープアライブ間隔",
"entries": " 件",
"local": "ローカル",
@@ -216,14 +216,14 @@
"layout": "レイアウト",
"tight": "コンパクト",
"standard": "標準",
"ultra": "最高",
"loose": "ゆったり",
"profilesSort": "プロファイルの並べ替え",
"start": "開始",
"runTime": "起動時間",
"runTime": "稼働時間",
"stop": "停止",
"key": "キー",
"value": "値",
"vpnTip": "変更はVPNの再起動後に有効になります",
"vpnEnableDesc": "VPN は VpnService でシステムの全トラフィックを自動的にルーティングします。",
"options": "オプション",
"enabled": "有効",
@@ -316,26 +316,20 @@
"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 の既定の保存ポリシーに従って保存されます。",
"disclaimerPrivacyContent": "本ソフトウェアは、サブスクリプション URL、ノード情報、設定内容、閲覧したウェブサイト、接続記録、通信内容、ログを収集またはアップロードしません。これらのデータは端末内にのみ保存され、開発者がアクセスすることはできません。\n\n本ソフトウェアが外部ネットワークにアクセスするのは、プロファイル更新時に指定されたサブスクリプション URL へアクセスする場合や、アップデート確認時に GitHub へアクセスする場合など、該当する機能を使用したときのみです。\n\n本ソフトウェアには、どのプラットフォームでも統計やクラッシュレポートのサービスは一切組み込まれていません。",
"disclaimerLicenseTitle": "オープンソースライセンス",
"disclaimerLicenseContent": "本ソフトウェアは GPL-3.0 ライセンスのもとでオープンソースとして公開されています。同ライセンスを遵守する限り、自由に使用、改変、再配布できますが、派生物も GPL-3.0 で公開し、原著作者の著作権表示を保持する必要があります。\n\n本ソフトウェアに含まれるサードパーティのコンポーネント(Clash.Meta コアを含む)は、それぞれのライセンスに従います。改変版や再配布版に起因する問題について、原著作者は責任を負いません。",
"disclaimerChangesTitle": "本声明の変更",
"disclaimerChangesContent": "開発者はバージョンの更新に伴い本声明を変更することがあり、変更内容は新しいバージョンの公開とともに効力を生じます。更新後も本ソフトウェアを引き続き使用した場合、変更後の声明に同意したものとみなされます。",
"disclaimerAcceptTitle": "声明への同意",
"disclaimerAcceptContent": "本ソフトウェアをインストール、複製または使用した時点で、本声明のすべての内容を読み、同意したものとみなされます。いずれかの条項に同意いただけない場合は、直ちに使用を中止し、本ソフトウェアをアンインストールしてください。",
"disclaimerAndroidOnly": "Android のみ",
"disclaimerGooglePrivacy": "Google プライバシーポリシー",
"disclaimerFirebasePrivacy": "Firebase のプライバシーとセキュリティ",
"disclaimerReadToEnd": "最後までお読みください",
"disclaimerRestateTitle": "声明の復唱",
"disclaimerRestateTip": "内容を理解したことを確認するため、以下の声明を下の入力欄に正確に入力してください:",
"disclaimerRestateHint": "上記の声明を入力",
"disclaimerRestateMismatch": "声明の内容と一致しません",
"disclaimerAcknowledgeTitle": "声明の確認",
"disclaimerAcknowledgeTip": "内容を理解したことを確認するため、以下の声明を読み、下のチェックボックスをオンにしてください:",
"disclaimerAcknowledgeLabel": "この声明を読み、理解しました",
"agree": "同意する",
"hotkeyManagement": "ホットキー管理",
"hotkeyDesc": "グローバルホットキーはウィンドウが非表示でも有効です。アクションをタップしてキーの組み合わせを記録します。",
@@ -405,12 +399,9 @@
"lineWrap": "折り返し",
"none": "なし",
"advancedConfig": "詳細設定",
"advancedConfigDesc": "ネットワーク、DNS、追加ルール、スクリプト",
"selectedCountTitle": "{count} 件選択中",
"advancedConfigDesc": "詳細設定には、ネットワーク、DNS、追加ルール、スクリプトがあります。",
"addRule": "ルールを追加",
"ruleName": "ルール名",
"content": "内容",
"subRule": "サブルール",
"ruleTarget": "ルールターゲット",
"matchTarget": "MATCH-TARGET",
"followProfile": "プロファイルに従う",
@@ -424,17 +415,19 @@
"slide": "スライド",
"fade": "フェード",
"pureBlack": "ピュアブラック",
"colorSchemes": "カラースキーム",
"palette": "パレット",
"tonalSpotScheme": "トーナルスポット",
"fidelityScheme": "フィデリティ",
"customColor": "カスタムカラー",
"colorStyle": "配色スタイル",
"grid": "グリッド",
"spectrum": "スペクトル",
"sliders": "スライダ",
"hue": "色相",
"saturation": "彩度",
"brightness": "明度",
"hex": "16進数",
"monochromeScheme": "モノクローム",
"neutralScheme": "ニュートラル",
"vibrantScheme": "ビブラント",
"expressiveScheme": "エクスプレッシブ",
"contentScheme": "コンテンツ",
"rainbowScheme": "レインボー",
"fruitSaladScheme": "フルーツサラダ",
"vividStyle": "ビビッド",
"tintedStyle": "ティント",
"softStyle": "ソフト",
"developerMode": "開発者モード",
"developerModeEnableTip": "開発者モードが有効になりました。",
"messageTest": "メッセージテスト",
@@ -514,10 +507,6 @@
"forceRestartCoreTip": "コアを強制再起動してもよろしいですか?",
"dnsHijacking": "DNSハイジャック",
"coreStatus": "コアの状態",
"dataCollectionTip": "データ収集について",
"dataCollectionContent": "本アプリは、安定性向上のために Firebase Crashlytics を使用してクラッシュ情報を収集します。\n収集されるデータにはデバイス情報とクラッシュの詳細が含まれますが、個人の機密データは含まれません。\nこの機能は設定で無効にできます。",
"crashlytics": "クラッシュ分析",
"crashlyticsTip": "クラッシュ分析を有効にすると、クラッシュ時に機密情報を含まないログをアップロードします。",
"appendSystemDns": "システムDNSを追加",
"editRule": "ルールを編集",
"extendMode": "拡張モード",
@@ -562,7 +551,7 @@
"customProfile": "カスタム",
"customProfileDesc": "アプリのプロキシと他のプロファイルからプロキシグループとルールを組み立てます",
"customProfileResources": "カスタムプロファイルのリソース",
"customProfileResourcesDesc": "プロキシ、ルールプロバイダー、フィルター、アイコンセット",
"customProfileResourcesDesc": "カスタムプロファイルのリソースには、プロキシ、ルールプロバイダー、フィルター、アイコンセットがあります。",
"unknownNetworkError": "不明なネットワークエラー",
"backupFromNewerVersion": "このバックアップは新しいバージョンのアプリで作成されています。アプリを更新してから復元してください",
"networkTimeoutError": "リクエストがタイムアウトしました。ネットワークまたはプロキシを確認してから再試行してください",
@@ -579,7 +568,6 @@
"invalidBackupFile": "無効なバックアップファイル",
"pruneCache": "キャッシュを整理",
"backupAndRestore": "バックアップと復元",
"backupAndRestoreDesc": "WebDAVまたはファイルでデータを同期します",
"restore": "復元",
"restoreSuccess": "復元が完了しました",
"restoreOnlyConfig": "プロファイルのみ復元",
@@ -587,9 +575,7 @@
"addProfile": "プロファイルを追加",
"delayTest": "遅延テスト",
"proxyGroupEmpty": "プロキシグループが空です",
"proxyGroupNameDuplicate": "プロキシグループ名が重複しています",
"confirmExitWindow": "現在のウィンドウを閉じてもよろしいですか?",
"selectProxyProviders": "プロキシプロバイダーを選択",
"optional": "任意",
"maxFailedTimes": "最大失敗回数",
"testInterval": "テスト間隔",
@@ -597,7 +583,6 @@
"expectedStatus": "期待するステータス",
"strategy": "戦略",
"tolerance": "許容値",
"selectProxies": "プロキシを選択",
"inputProxyGroupName": "プロキシグループ名を入力してください",
"helperCorruptTip": "Helper サービスが利用できないため、TUN モードを有効にできません。FlClash を再インストールしてください。",
"tunAuthorizationFailed": "TUN の権限を取得できなかったため、オフにしました。",
@@ -614,7 +599,6 @@
"selectRuleSet": "ルールセットを選択してください",
"splitStrategy": "振り分け戦略",
"selectSplitStrategy": "振り分け戦略を選択してください",
"selectSubRule": "サブルールを選択してください",
"noResolveHostname": "ホスト名を解決しない",
"matchSourceIp": "送信元IPにマッチ",
"basicInfo": "基本情報",
@@ -622,14 +606,11 @@
"proxyType": "プロキシタイプ",
"basicStrategy": "基本ポリシー",
"editProxy": "プロキシを編集",
"addProxyProviders": "プロキシプロバイダーを追加",
"proxiesEmpty": "プロキシが空です",
"addNodes": "ノードを追加",
"addProxyGroup": "プロキシグループを追加",
"editProxyGroup": "プロキシグループを編集",
"iconUrl": "アイコンURL",
"iconRecords": "アイコン履歴",
"noRecords": "記録がありません",
"custom": "カスタム",
"ruleActionDomainDesc": "完全なドメインにマッチ",
"ruleActionDomainSuffixDesc": "ドメインサフィックスにマッチ",
@@ -668,12 +649,8 @@
"ruleActionNotDesc": "論理ルール NOT",
"ruleActionSubRuleDesc": "サブルールへマッチします。括弧の使い方に注意してください",
"ruleActionMatchDesc": "すべてのリクエストにマッチ(条件不要)",
"subRuleEmpty": "サブルールが空です",
"proxyProvidersNotEmpty": "プロキシプロバイダーは空にできません",
"contentNotEmpty": "内容は空にできません",
"subRuleNotEmpty": "サブルールは空にできません",
"splitStrategyNotEmpty": "振り分け戦略は空にできません",
"proxyProvidersEmpty": "プロキシプロバイダーが空です",
"timeout": "タイムアウト",
"delayFailed": "失敗",
"invalidSubRule": "{subRule} は無効な SUB_RULE です",
@@ -709,13 +686,14 @@
"resourceUpdateInterval": "自動更新間隔",
"resourceUpdateIntervalTip": "自動更新間隔は0より大きくしてください",
"hours": "時間",
"hoursCount": "{count} 時間",
"minutes": "分",
"geoResources": "Geo",
"geoSkipped": "{name} はすでに最新です",
"geoUpdated": "{name} を更新しました",
"secondsCount": "{count} 秒",
"proxiesCount": "{count, plural, other{プロキシ {label} 件}}",
"rulesCount": "{count, plural, other{ルール {label} 件}}",
"proxyGroupsCount": "{count, plural, other{プロキシグループ {label} 件}}",
"changelogBreaking": "破壊的変更",
"changelogFeatures": "新機能",
"changelogFixes": "不具合修正",
@@ -740,7 +718,6 @@
"nextMatch": "次の一致",
"clearSearch": "検索をクリア",
"noSearchResults": "一致する結果はありません",
"addWidget": "ウィジェットを追加",
"showPassword": "パスワードを表示",
"hidePassword": "パスワードを隠す",
"authorize": "許可",
@@ -832,6 +809,7 @@
"filters": "フィルター",
"label": "ラベル",
"regex": "正規表現",
"matchCase": "大文字と小文字を区別",
"iconSets": "アイコンセット",
"invalidIconSet": "有効なアイコンセットではありません",
"recommendedIcons": "おすすめ",
@@ -840,6 +818,7 @@
"outbound": "アウトバウンド",
"strictRoute": "厳格なルーティング",
"congestionController": "TCP 輻輳制御",
"congestionControllerDesc": "TCP 輻輳制御は mips スタックでのみ有効です。",
"routeExcludeAddress": "ルート除外アドレス",
"keepAliveIdle": "TCP キープアライブ待機時間",
"disableKeepAlive": "TCP キープアライブを無効化",
@@ -856,7 +835,22 @@
"disableIcmpForwarding": "ICMP 転送を無効化",
"disableIcmpForwardingDesc": "ICMP 転送を無効にすると TUN が ping に直接応答するため、ping に実際の遅延が表示されなくなります。",
"excludeInterface": "除外インターフェース",
"excludeInterfaceDesc": "除外インターフェースは Linux でのみ有効です。docker0 などから入る通信は TUN を通りません。",
"excludeInterfaceDesc": "docker0 などの除外インターフェースから入る通信は TUN を通りません。",
"includeInterface": "対象インターフェース",
"includeInterfaceDesc": "対象インターフェースから入る通信だけが TUN を通り、除外インターフェースは無効になります。",
"includeUid": "対象ユーザー ID",
"includeUidDesc": "対象ユーザー ID のプロセスの通信だけが TUN を通ります。",
"excludeUid": "除外ユーザー ID",
"excludeUidDesc": "除外ユーザー ID のプロセスの通信は TUN を通りません。",
"excludeSrcPort": "除外送信元ポート",
"excludeSrcPortDesc": "除外送信元ポートからの通信は TUN を通りません。",
"excludeDstPort": "除外宛先ポート",
"excludeDstPortDesc": "除外宛先ポートへの通信は TUN を通りません。",
"udpTimeout": "UDP タイムアウト",
"udpTimeoutDesc": "UDP タイムアウトは秒単位で、gvisor・system・mixed スタックでのみ有効です。",
"dnsHijackDesc": "追加しない場合、ポート 53 宛てのすべての DNS 問い合わせをハイジャックします(any:53)。",
"tunOverrideDesc": "ここで追加した項目は、すべてのプロファイルの TUN 設定を上書きします。追加していない項目はプロファイル自身の設定のままです。カスタムプロファイル自身が追加した項目が優先されます。",
"profileTunSettingsDesc": "このプロファイルにのみ適用され、ネットワーク設定の TUN 上書き項目より優先されます。",
"settingsCount": "設定 {count} 件",
"sectionsInEffect": "{count} 件が有効",
"itemsCount": "{count} 件",
@@ -905,5 +899,45 @@
"required": "必須",
"ageSecretKey": "age 秘密鍵",
"ageEncryptedProfile": "暗号化されたプロファイル",
"ageSecretKeyRequiredTip": "このプロファイルは age で暗号化されており、復号には age 秘密鍵が必要です"
"ageSecretKeyRequiredTip": "このプロファイルは age で暗号化されており、復号には age 秘密鍵が必要です",
"currentOutboundDesc": "起動ボタンがあるため削除できません",
"running": "実行中",
"notRunning": "停止中",
"trafficPeak": "ピーク {value}",
"trafficTotal": "累計 {value}",
"ipQualityShort": "品質",
"ipTypeUnknown": "タイプ不明",
"tapIconToRetry": "アイコンをタップして再試行",
"networkTakeover": "トラフィックの引き継ぎ",
"portValue": "ポート {port}",
"serviceAbnormalCount": "{count} 件に問題",
"serviceAllAvailable": "すべて利用可能",
"serviceShownOnDashboard": "ダッシュボードに表示",
"groupTimeoutCount": "{count} 件タイムアウト",
"groupCountUnit": "個",
"groupCount": "{count} 個",
"proxyGroupsAfterStart": "起動するとここにグループが表示されます",
"usedShort": "使用済み",
"profileUpdatedAt": "{time}に更新",
"profileExpiresIn": "{date} に期限切れ、残り {days} 日",
"profileDaysLeft": "残り {days} 日",
"target": "宛先",
"outboundExit": "出口",
"duration": "経過時間",
"traffic": "通信量",
"totalDownload": "ダウンロード量",
"totalUpload": "アップロード量",
"initiatorShort": "発信元",
"result": "結果",
"elapsed": "所要時間",
"connectionStats": "接続統計",
"connectionStatsDesc": "ライブ接続、最近のリクエスト、DNS クエリ",
"memoryInfoDesc": "アプリとコアが使用するメモリ",
"editDashboard": "ダッシュボードを編集",
"done": "完了",
"dashboardAddableSection": "追加できるカード",
"customImportDroppedSubRules": "{count, plural, other{カスタム設定はサブルールに対応していないため、SUB-RULE ルール {label} 件を破棄しました}}",
"autoInclude": "自動で含める",
"allReferencedProxies": "参照されているすべてのプロキシ",
"allReferencedProxyProviders": "参照されているすべてのプロキシプロバイダー"
}
+943
View File
@@ -0,0 +1,943 @@
{
"rule": "규칙",
"rules": "규칙",
"global": "전역",
"direct": "직접",
"dashboard": "대시보드",
"proxies": "프록시",
"profile": "프로필",
"profiles": "프로필",
"tools": "도구",
"logs": "로그",
"resources": "리소스",
"trafficUsage": "트래픽 사용량",
"networkSpeed": "네트워크 속도",
"outboundMode": "아웃바운드 모드",
"serviceStatus": "서비스 상태",
"serviceRestricted": "접근 제한됨",
"serviceAvailable": "사용 가능",
"serviceUnavailable": "사용 불가",
"serviceFailed": "확인 실패",
"serviceDisallowedIsp": "허용되지 않는 ISP",
"serviceBlocked": "차단됨",
"serviceUnsupportedRegion": "지원하지 않는 지역",
"serviceOriginalsOnly": "오리지널 콘텐츠만",
"serviceComingSoon": "출시 예정",
"servicePending": "확인 전",
"serviceManage": "서비스 관리",
"serviceCheck": "확인",
"serviceCheckAll": "모두 확인",
"serviceCheckedAt": "확인 시각: {time}",
"upload": "업로드",
"download": "다운로드",
"usedTraffic": "사용한 트래픽",
"totalTraffic": "전체 트래픽",
"expireTime": "만료 시간",
"nullProfileDesc": "프로필을 추가하여 시작하세요",
"settings": "설정",
"language": "언어",
"defaultText": "기본값",
"more": "더 보기",
"other": "기타",
"about": "정보",
"en": "영어",
"es": "스페인어",
"fr": "프랑스어",
"ja": "일본어",
"ko": "한국어",
"ru": "러시아어",
"zhCN": "중국어 간체",
"theme": "테마",
"extend": "확장",
"runtimeConfig": "런타임 구성",
"allowLan": "LAN 허용",
"tun": "TUN",
"tunDesc": "TUN은 관리자 모드에서만 작동합니다.",
"minimizeOnExit": "종료 시 최소화",
"autoLaunch": "자동 실행",
"silentLaunch": "백그라운드 실행",
"silentLaunchDesc": "백그라운드 실행은 창을 표시하지 않고 시작합니다.",
"autoRun": "자동 시작",
"autoRunDesc": "자동 시작은 앱을 열 때 프록시를 시작합니다.",
"logcat": "Logcat",
"logcatDesc": "Logcat을 끄면 로그 항목이 숨겨집니다.",
"autoCheckUpdate": "업데이트 자동 확인",
"checkCertificate": "TLS 인증서 검증",
"checkCertificateDesc": "TLS 검증을 끄면 구독과 백업이 중간자 공격에 노출됩니다.",
"accessControl": "접근 제어",
"accessControlDesc": "접근 제어는 프록시를 사용할 앱을 선택합니다.",
"edit": "편집",
"confirm": "확인",
"update": "업데이트",
"add": "추가",
"save": "저장",
"delete": "삭제",
"seconds": "초",
"qrcode": "QR 코드",
"qrcodeDesc": "QR 코드를 스캔하여 프로필 가져오기",
"url": "URL",
"link": "링크",
"linkDesc": "구독 링크 또는 프록시 공유 링크로 프로필 가져오기",
"file": "파일",
"fileDesc": "프로필 파일을 직접 업로드",
"name": "이름",
"profileNameNullValidationDesc": "프로필 이름을 입력하세요",
"rename": "이름 변경",
"profileUrlNullValidationDesc": "프로필 URL을 입력하세요",
"profileUrlInvalidValidationDesc": "올바른 프로필 URL을 입력하세요",
"autoUpdate": "자동 업데이트",
"autoUpdateInterval": "자동 업데이트 간격",
"profileAutoUpdateIntervalNullValidationDesc": "자동 업데이트 간격을 입력하세요",
"profileAutoUpdateIntervalInvalidValidationDesc": "올바른 간격을 입력하세요",
"themeMode": "테마 모드",
"themeColor": "테마 색상",
"preview": "미리보기",
"auto": "자동",
"light": "라이트",
"dark": "다크",
"importFromLink": "링크에서 가져오기",
"submit": "제출",
"createProfileFromUrlTip": "{url}에서 프로필을 만드시겠습니까?",
"create": "만들기",
"pleaseUploadValidQrcode": "올바른 QR 코드를 업로드하세요",
"blacklistMode": "블랙리스트 모드",
"whitelistMode": "화이트리스트 모드",
"selectAll": "모두 선택",
"cancelSelectAll": "모두 선택 해제",
"appAccessControl": "앱 접근 제어",
"accessControlAllowDesc": "선택한 앱만 VPN을 거칩니다",
"accessControlNotAllowDesc": "선택한 앱은 VPN에서 제외됩니다",
"accessControlDisabledDesc": "앱 접근 제어가 꺼져 있습니다",
"selected": "선택됨",
"port": "포트",
"logLevel": "로그 수준",
"show": "표시",
"exit": "종료",
"systemProxy": "시스템 프록시",
"safeMode": "안전 모드",
"safeModeAppTitle": "{appName} (안전 모드)",
"project": "프로젝트",
"core": "코어",
"tabAnimation": "탭 애니메이션",
"desc": "ClashMeta 기반의 멀티 플랫폼 프록시 클라이언트로, 간편하게 사용할 수 있으며 오픈 소스이고 광고가 없습니다.",
"startVpn": "VPN 시작 중…",
"stopVpn": "VPN 중지 중…",
"localNetworkDeniedTip": "로컬 네트워크 권한이 거부되었습니다. gvisor 스택을 사용하므로 LAN에 연결할 수 없습니다.",
"compatible": "호환 모드",
"notSelectedTip": "현재 프록시 그룹은 선택할 수 없습니다",
"changeProxyFailedTip": "프록시를 전환하지 못해 이전 선택을 복원했습니다",
"databaseWriteFailedTip": "변경 사항을 저장하지 못해 되돌렸습니다",
"tip": "안내",
"account": "계정",
"backup": "백업",
"backupSuccess": "백업 완료",
"noInfo": "정보 없음",
"pleaseBindWebDAV": "WebDAV를 연결하세요",
"bind": "연결",
"connectivity": "연결 상태: ",
"webDAVConfiguration": "WebDAV 구성",
"address": "주소",
"addressHelp": "WebDAV 서버 주소",
"addressTip": "올바른 WebDAV 주소를 입력하세요",
"password": "비밀번호",
"authentication": "인증",
"authenticationDesc": "인증을 켜면 로컬 프록시 포트에서 사용자 이름과 비밀번호를 요구합니다.",
"authenticationSystemProxyDesc": "인증이 켜져 있는 동안에는 시스템 프록시가 적용되지 않습니다.",
"checkUpdate": "업데이트 확인",
"discoverNewVersion": "새 버전 발견",
"checkUpdateError": "이미 최신 버전입니다",
"goDownload": "다운로드",
"unknown": "알 수 없음",
"country": "지역",
"search": "검색",
"allowBypass": "앱의 VPN 우회 허용",
"externalController": "외부 컨트롤러",
"externalControllerDesc": "외부 컨트롤러를 사용하면 9090 포트로 코어를 제어할 수 있습니다.",
"externalControllerSecret": "외부 컨트롤러 시크릿",
"ipv6Desc": "IPv6 트래픽은 IPv6가 켜져 있을 때만 수신됩니다.",
"app": "앱",
"general": "일반",
"startupAndBackground": "시작 및 백그라운드",
"requestsAndUpdates": "요청 및 업데이트",
"logsAndDiagnostics": "로그 및 진단",
"userAgent": "User-Agent",
"unifiedDelay": "통합 지연",
"tcpConcurrent": "TCP 동시 연결",
"geodataLoader": "Geo 저메모리 모드",
"requests": "요청",
"recentRequests": "최근 요청",
"findProcessMode": "프로세스 찾기",
"init": "초기화",
"infiniteTime": "만료되지 않음",
"connections": "연결",
"liveConnections": "실시간 연결",
"inbound": "인바운드",
"intranetIP": "내부망 IP",
"view": "보기",
"cut": "잘라내기",
"copy": "복사",
"clipboardWriteFailed": "클립보드에 복사하지 못했습니다. 선택 영역이 너무 클 수 있습니다",
"paste": "붙여넣기",
"testUrl": "테스트 URL",
"sync": "동기화",
"exclude": "최근 작업에서 숨기기",
"compact": "간결하게",
"tab": "탭",
"list": "목록",
"delay": "지연",
"style": "스타일",
"size": "크기",
"sort": "정렬",
"columns": "열",
"proxyGroup": "프록시 그룹",
"go": "이동",
"externalLink": "외부 링크",
"otherContributors": "기타 기여자",
"appIconDesign": "앱 아이콘 디자인",
"autoCloseConnections": "연결 자동 종료",
"autoCloseConnectionsDesc": "연결 자동 종료는 프록시를 전환할 때 열려 있는 연결을 끊습니다.",
"onlyStatisticsProxy": "프록시 트래픽만 집계",
"showNotificationStopAction": "알림에 중지 버튼 표시",
"pureBlackMode": "퓨어 블랙 모드",
"sidebarBlur": "사이드바 블러",
"sidebarBlurDesc": "사이드바를 통해 창 뒤의 흐릿한 바탕화면을 표시합니다",
"liquidGlass": "Liquid Glass",
"keepAliveIntervalDesc": "TCP 킵얼라이브 간격",
"entries": "개 항목",
"local": "로컬",
"remote": "원격",
"mode": "모드",
"time": "시간",
"source": "소스",
"action": "동작",
"intelligentSelected": "스마트 선택",
"clipboardImport": "클립보드에서 가져오기",
"clipboardExport": "클립보드로 내보내기",
"layout": "레이아웃",
"tight": "좁게",
"standard": "표준",
"ultra": "최고",
"loose": "넓게",
"profilesSort": "프로필 정렬",
"start": "시작",
"runTime": "실행 시간",
"stop": "중지",
"key": "키",
"value": "값",
"vpnEnableDesc": "VPN은 VpnService를 통해 모든 시스템 트래픽을 자동으로 라우팅합니다.",
"options": "옵션",
"enabled": "사용",
"disabled": "사용 안 함",
"loopback": "UWP 루프백 예외",
"providers": "외부 리소스",
"proxyProviders": "프록시 공급자",
"ruleProviders": "규칙 공급자",
"providerUrlTip": "원격 공급자만 지원합니다",
"ruleSetEmptyTip": "규칙 세트에 사용할 수 있는 항목이 없습니다",
"ruleSetMixedTip": "규칙 세트에 도메인과 IP 범위가 섞여 있어 유형을 판별할 수 없습니다",
"ruleSetInvalidTip": "이 mrs 파일을 읽을 수 없습니다",
"subscriptionInfo": "구독 정보",
"ntpStatusDesc": "NTP는 시스템 시계 대신 NTP 서버에서 시간을 가져옵니다.",
"server": "서버",
"ntpInterval": "동기화 간격(분)",
"dialerProxy": "다이얼러 프록시",
"dialerProxyDesc": "다이얼러 프록시는 NTP 서버에 연결할 때 사용하는 아웃바운드입니다.",
"writeToSystem": "시스템에 기록",
"writeToSystemDesc": "시스템에 기록하면 시스템 시계도 설정합니다. Android에서는 무시됩니다.",
"sniffer": "스니퍼",
"snifferStatusDesc": "스니퍼는 TLS, HTTP, QUIC 트래픽에서 도메인을 읽어 규칙이 일치할 수 있게 합니다.",
"overrideDestination": "대상 덮어쓰기",
"overrideDestinationDesc": "대상 덮어쓰기는 원래 주소 대신 스니핑한 도메인으로 연결합니다.",
"forceDnsMapping": "DNS 매핑 강제",
"forceDnsMappingDesc": "DNS 매핑 강제는 DNS 매핑에서 도메인을 얻은 연결도 스니핑합니다.",
"parsePureIp": "순수 IP 분석",
"parsePureIpDesc": "순수 IP 분석은 IP 주소만 있는 연결을 스니핑합니다.",
"forceDomain": "강제 도메인",
"forceDomainDesc": "강제 도메인은 항상 스니핑합니다. 이미 도메인이 있는 다른 연결은 그대로 둡니다.",
"skipDomain": "건너뛸 도메인",
"skipDomainDesc": "건너뛸 도메인 목록에 있는 스니핑된 도메인은 사용하지 않습니다.",
"skipSrcAddress": "건너뛸 출발지 주소",
"skipDstAddress": "건너뛸 목적지 주소",
"sniffProtocols": "프로토콜",
"ports": "포트",
"overrideEntries": "덮어쓰기 항목",
"addOverrideEntry": "덮어쓰기 항목 추가",
"dnsOverrideDesc": "여기에 추가한 항목은 모든 URL 및 파일 프로필의 DNS를 대체합니다. 추가하지 않은 항목은 프로필 자체 설정을 유지합니다. 사용자 지정 프로필은 내용에서 DNS를 설정합니다.",
"settingEntries": "설정",
"addSettingEntry": "설정 추가",
"profileSettingsDesc": "이 프로필에만 적용되며, 추가하지 않은 항목은 기본값을 사용합니다.",
"healthCheck": "상태 확인",
"discardChanges": "변경 사항을 취소하시겠습니까?",
"quickEdit": "빠른 편집",
"status": "상태",
"statusDesc": "DNS를 끄면 시스템 DNS를 사용합니다.",
"respectRules": "규칙 준수",
"respectRulesDesc": "규칙 준수는 DNS 연결을 규칙에 따라 라우팅하며 Proxy Server Nameserver가 필요합니다.",
"dnsMode": "DNS 모드",
"fakeipRange": "Fake-IP 범위",
"fakeipFilter": "Fake-IP 필터",
"useHosts": "hosts 사용",
"useSystemHosts": "시스템 hosts 사용",
"fallbackFilter": "폴백 필터",
"domain": "도메인",
"listenRoutingMark": "수신 라우팅 마크",
"listenRoutingMarkDesc": "수신 라우팅 마크는 Linux에서만 작동합니다.",
"ipv6Timeout": "IPv6 타임아웃(ms)",
"cacheAlgorithm": "캐시 알고리즘",
"cacheMaxSize": "캐시 크기",
"fakeipRange6": "Fake-IP 범위(IPv6)",
"fakeipFilterMode": "Fake-IP 필터 모드",
"fakeipFilterModeDesc": "Fake-IP 필터 모드에서 블랙리스트는 일치 항목을 제외하고, 화이트리스트는 일치 항목에만 가짜 IP를 사용하며, 규칙은 규칙에 따라 일치시킵니다.",
"fakeipTtl": "Fake-IP TTL",
"reset": "초기화",
"actionView": "표시/숨기기",
"actionStart": "시작/중지",
"actionMode": "모드 전환",
"actionProxy": "시스템 프록시",
"actionTun": "TUN",
"actionRuleMode": "규칙 모드",
"actionGlobalMode": "전역 모드",
"actionDirectMode": "직접 모드",
"actionDelayTest": "모든 지연 테스트",
"actionUpdateProfiles": "프로필 업데이트",
"disclaimer": "면책 조항",
"disclaimerDesc": "FlClash(이하 “본 소프트웨어”)를 사용하기 전에 이 문서를 주의 깊게 읽고 모든 내용을 이해했는지 확인하세요. “동의”를 누르면 아래 모든 조항을 읽고 이해했으며 수락한 것으로 간주합니다. 동의하지 않으면 “종료”를 누르고 본 소프트웨어의 사용을 중단하세요.",
"disclaimerSoftwareTitle": "소프트웨어의 성격",
"disclaimerSoftwareContent": "본 소프트웨어는 Clash.Meta(mihomo) 코어를 기반으로 한 오픈 소스 네트워크 프록시 클라이언트입니다. 구성 관리, 규칙 기반 라우팅, 트래픽 전달과 같은 로컬 도구만 제공합니다.",
"disclaimerNoServiceStatement": "본 소프트웨어 자체는 프록시 서버, 노드, 구독, 네트워크 접속 서비스를 일절 제공하지 않으며, 이러한 서비스의 어떤 제공자와도 제휴, 대리 또는 보증 관계가 없습니다.",
"disclaimerUsageTitle": "사용 제한",
"disclaimerUsageContent": "본 소프트웨어는 학습, 교류, 기술 연구와 같은 비상업적 용도로만 제공됩니다. 유료 배포, 끼워 팔기, 상업 서비스의 일부로 사용, 본 소프트웨어의 이름을 내세운 영업 등 어떠한 상업적 목적의 사용도 엄격히 금지되며, 이에 국한되지 않습니다. 모든 상업 활동은 본 소프트웨어 및 개발자와 무관합니다.\n\n거주 국가 또는 지역의 법령을 위반하는 용도로 본 소프트웨어를 사용하는 것은 엄격히 금지됩니다. 여기에는 합법적으로 부과된 네트워크 접속 제한 우회, 불법 콘텐츠 유포, 네트워크 공격, 타인의 합법적 권리 침해가 포함되며 이에 국한되지 않습니다.",
"disclaimerResponsibilityTitle": "사용자의 책임",
"disclaimerResponsibilityContent": "본 소프트웨어의 사용이 거주지에서 적법한지 확인할 책임은 사용자에게 있으며, 본 소프트웨어로 한 모든 행위와 그 결과에 대한 법적 책임은 사용자 본인이 부담합니다.\n\n가져오는 구독, 노드, 구성은 사용자 본인의 선택입니다. 출처의 적법성, 콘텐츠의 안전성, 서비스의 신뢰성은 사용자와 해당 제공자 사이의 문제입니다.",
"disclaimerThirdPartyTitle": "제3자 콘텐츠",
"disclaimerThirdPartyContent": "구독 링크, 구성 파일, 규칙 세트, 스크립트, 외부 리소스, 외부 링크는 모두 제3자가 제공합니다. 개발자는 이들의 적법성, 정확성, 안전성, 가용성을 심사하거나 보증할 수 없으며 보증하지 않습니다.\n\n제3자 콘텐츠로 인한 데이터 유출, 금전적 손실, 계정 정지 및 기타 손해는 사용자와 해당 제3자 사이에서 해결해야 하며, 개발자는 이에 대해 책임을 지지 않습니다.",
"disclaimerWarrantyTitle": "보증 부인",
"disclaimerWarrantyContent": "본 소프트웨어는 “있는 그대로”, “이용 가능한 상태로” 제공되며, 상품성, 특정 목적에의 적합성, 비침해성, 중단 없는 이용 가능성, 오류 없음, 보안 취약점 없음에 대한 보증을 포함하되 이에 국한되지 않고 명시적이든 묵시적이든 어떠한 종류의 보증도 하지 않습니다.\n\n개발자는 본 소프트웨어가 사용자의 요구를 충족하거나 중단 및 오류 없이 실행될 것임을 보증하지 않습니다.",
"disclaimerLiabilityTitle": "책임의 제한",
"disclaimerLiabilityContent": "관련 법률이 허용하는 최대 범위에서, 개발자와 기여자는 본 소프트웨어의 사용 또는 사용 불능으로 발생하는 직접, 간접, 부수적, 특별, 징벌적 또는 결과적 손해에 대해 책임을 지지 않습니다. 여기에는 데이터 손실, 기기 손상, 네트워크 장애, 사업 중단, 이익 손실 및 그로 인한 법적 분쟁이 포함되며 이에 국한되지 않고, 그러한 손해의 가능성을 사전에 통지받은 경우에도 마찬가지입니다.",
"disclaimerPrivacyTitle": "데이터 수집 및 개인정보 보호",
"disclaimerPrivacyContent": "본 소프트웨어는 구독 URL, 노드 정보, 구성 내용, 방문한 웹사이트, 연결 기록, 트래픽 내용, 로그를 수집하거나 업로드하지 않습니다. 이 데이터는 사용자의 기기에만 저장되며 개발자는 접근할 수 없습니다.\n\n본 소프트웨어는 프로필을 업데이트할 때 사용자가 입력한 구독 URL을 가져오거나 업데이트를 확인할 때 GitHub에 접속하는 등 기능이 필요할 때만 네트워크에 접속합니다.\n\n본 소프트웨어에는 어떤 플랫폼에서도 분석 또는 비정상 종료 보고 서비스가 포함되어 있지 않습니다.",
"disclaimerLicenseTitle": "오픈 소스 라이선스",
"disclaimerLicenseContent": "본 소프트웨어는 GPL-3.0 라이선스로 공개된 오픈 소스입니다. 파생 저작물도 GPL-3.0으로 배포하고 원저작권 고지를 유지해야 하는 라이선스 조건을 준수하는 한 자유롭게 사용, 수정, 배포할 수 있습니다.\n\nClash.Meta 코어를 포함한 본 소프트웨어의 제3자 구성 요소는 각자의 라이선스를 따릅니다. 원저작자는 수정 또는 재배포된 버전에서 발생하는 어떠한 문제에도 책임을 지지 않습니다.",
"disclaimerChangesTitle": "본 문서의 변경",
"disclaimerChangesContent": "개발자는 릴리스와 함께 본 문서를 개정할 수 있으며, 개정 내용은 해당 릴리스가 공개되는 시점에 효력이 발생합니다. 업데이트 후에도 본 소프트웨어를 계속 사용하면 개정된 문서를 수락한 것으로 간주합니다.",
"disclaimerAcceptTitle": "동의",
"disclaimerAcceptContent": "본 소프트웨어를 설치, 복사 또는 사용하면 이 문서 전체를 읽고 동의한 것으로 간주합니다. 어느 조항이라도 동의하지 않는 경우 즉시 본 소프트웨어의 사용을 중단하고 삭제하세요.",
"disclaimerRestateTitle": "문구 다시 입력",
"disclaimerRestateTip": "내용을 이해했는지 확인하기 위해 아래 입력란에 다음 문구를 그대로 입력하세요.",
"disclaimerRestateHint": "위 문구를 입력하세요",
"disclaimerRestateMismatch": "문구와 일치하지 않습니다",
"disclaimerAcknowledgeTitle": "문구 확인",
"disclaimerAcknowledgeTip": "내용을 이해했는지 확인하기 위해 다음 문구를 읽고 아래 항목에 체크하세요.",
"disclaimerAcknowledgeLabel": "이 문구를 읽고 이해했습니다",
"agree": "동의",
"hotkeyManagement": "단축키 관리",
"hotkeyDesc": "전역 단축키는 창이 숨겨져 있어도 작동합니다. 동작을 눌러 키 조합을 기록하세요.",
"hotkeyNotSet": "설정 안 됨",
"pressKeyboard": "키 조합을 누르세요",
"hotkeyNeedsModifier": "{modifiers} 중 하나 이상을 포함하세요",
"hotkeyConflictWith": "이미 “{action}”에서 사용 중입니다. 저장하면 이쪽으로 옮겨집니다.",
"hotkeyUnavailable": "등록되지 않음: 다른 앱이 사용 중일 수 있습니다",
"remove": "제거",
"noNetwork": "네트워크 없음",
"ipv6InboundDesc": "IPv6를 켜면 VPN이 IPv6 인바운드를 허용합니다.",
"exportLogs": "로그 내보내기",
"exportSuccess": "내보내기 완료",
"iconStyle": "아이콘 스타일",
"iconStyleFilled": "채움",
"iconStylePlain": "기본",
"iconStyleHidden": "숨김",
"filter": "필터",
"hideTimeoutProxies": "시간 초과된 프록시 숨기기",
"hideTimeoutProxiesDesc": "마지막 지연 테스트에서 시간 초과된 프록시를 제외합니다",
"stackMode": "스택 모드",
"network": "네트워크",
"bypassDomain": "우회 도메인",
"bypassDomainDesc": "우회 도메인은 시스템 프록시가 켜져 있을 때만 적용됩니다.",
"resetTip": "초기화하시겠습니까?",
"icon": "아이콘",
"noData": "데이터 없음",
"fontFamily": "글꼴",
"installedFonts": "설치된 글꼴",
"toggle": "전환",
"system": "시스템",
"routeAddress": "라우팅 주소",
"interfaceNameMode": "아웃바운드 인터페이스",
"interfaceNameModeClear": "지정 안 함",
"interfaceNameModeFollow": "구성 따르기",
"interfaceNameModeCustom": "사용자 지정",
"interfaceName": "인터페이스 이름",
"interfaceNameDesc": "인터페이스 이름은 아웃바운드 연결이 사용하는 네트워크 인터페이스입니다.",
"copyEnvVar": "환경 변수 복사",
"memoryInfo": "메모리 정보",
"total": "합계",
"releaseMemory": "메모리 해제",
"memoryCoreHeapInuse": "사용 중인 힙",
"memoryCoreHeapIdle": "유휴 힙",
"memoryCoreStack": "고루틴 스택",
"memoryCoreRuntime": "런타임 오버헤드",
"memoryCoreNotRunning": "코어가 실행 중이 아닙니다",
"memoryAppResident": "상주 메모리",
"memoryAppShared": "앱 및 공유",
"memoryEstimateDesc": "프로세스 상주 메모리로 추정한 값이며 시스템에 표시되는 값과 다를 수 있습니다.",
"memoryEstimateSharedDesc": "코어는 앱 프로세스 안에서 실행됩니다. 코어의 점유량은 런타임 통계로 추정하며, 나머지는 앱 및 공유 메모리로 집계합니다.",
"memoryReleased": "메모리를 해제했습니다",
"memoryReleasedSize": "{size} 해제됨",
"releaseMemoryFailed": "메모리를 해제하지 못했습니다",
"cancel": "취소",
"fileIsUpdate": "파일이 수정되었습니다. 변경 사항을 저장하시겠습니까?",
"profileHasUpdate": "프로필이 수정되었습니다. 자동 업데이트를 끄시겠습니까?",
"hasCacheChange": "변경 사항을 캐시하시겠습니까?",
"copySuccess": "복사했습니다",
"copyLink": "링크 복사",
"exportFile": "파일 내보내기",
"cacheCorrupt": "캐시가 손상되었습니다. 지우시겠습니까?",
"detectionTip": "서드파티 API에 의존하므로 참고용으로만 사용하세요",
"listen": "수신",
"undo": "실행 취소",
"redo": "다시 실행",
"lineWrap": "자동 줄 바꿈",
"none": "없음",
"advancedConfig": "고급 구성",
"advancedConfigDesc": "고급 구성에는 네트워크, DNS, 추가한 규칙, 스크립트가 포함됩니다.",
"addRule": "규칙 추가",
"content": "내용",
"ruleTarget": "규칙 대상",
"matchTarget": "MATCH-TARGET",
"followProfile": "프로필 따르기",
"sourceIp": "출발지 IP",
"noResolve": "IP 확인 안 함",
"saveChanges": "변경 사항을 저장하시겠습니까?",
"cannotSave": "저장할 수 없음",
"navigationBarStyle": "하단 바",
"floating": "플로팅",
"docked": "고정",
"slide": "슬라이드",
"fade": "페이드",
"pureBlack": "퓨어 블랙",
"customColor": "사용자 지정 색상",
"colorStyle": "색상 스타일",
"grid": "격자",
"spectrum": "스펙트럼",
"sliders": "슬라이더",
"hue": "색조",
"saturation": "채도",
"brightness": "밝기",
"hex": "16진수",
"monochromeScheme": "모노크롬",
"vividStyle": "선명",
"tintedStyle": "틴트",
"softStyle": "부드러움",
"developerMode": "개발자 모드",
"developerModeEnableTip": "개발자 모드가 켜졌습니다.",
"messageTest": "메시지 테스트",
"messageTestTip": "이것은 메시지입니다.",
"crashTest": "비정상 종료 테스트",
"crashDetected": "비정상 종료 감지됨",
"crashDetectedTip": "앱이 두 번 연속 시작을 완료하지 못했습니다. 반복을 끊기 위해 프로필 {name}의 선택을 해제하고 자동 설정을 건너뛰었습니다. 언제든지 다시 선택할 수 있습니다.",
"launchInterrupted": "시작이 완료되지 않음",
"launchInterruptedTip": "지난번 시작하는 중에 앱이 예기치 않게 종료되었습니다. 이번 실행에서는 자동 설정을 건너뛰었으니 직접 시작하여 다시 시도하세요.",
"clearData": "데이터 지우기",
"textScale": "글자 크기 조절",
"textScalePreview": "앱의 글자가 이렇게 표시됩니다",
"followSystem": "시스템 설정 따르기",
"internet": "인터넷",
"systemApp": "시스템 앱",
"noNetworkApp": "네트워크 미사용 앱",
"restoreStrategy": "복원 방식",
"restoreStrategyOverride": "덮어쓰기",
"restoreStrategyCompatible": "호환",
"logsTest": "로그 테스트",
"emptyTip": "{label}을(를) 입력하세요",
"urlTip": "{label}은(는) URL이어야 합니다",
"invalidLinkTip": "구독 링크 또는 프록시 공유 링크가 아닙니다",
"numberTip": "{label}은(는) 숫자여야 합니다",
"numberRangeTip": "{label}은(는) {min}에서 {max} 사이여야 합니다",
"interval": "간격",
"existsTip": "{label}이(가) 이미 있습니다",
"maxLengthTip": "{label}은(는) 최대 {max}자까지 입력할 수 있습니다",
"singleAdd": "개별 추가",
"batchAdd": "일괄 추가",
"batchListInputTip": "한 줄에 하나씩 또는 쉼표로 구분하여 입력하세요",
"batchMapInputTip": "한 줄에 하나씩, 키, 공백, 값 순서로 입력하세요",
"singleImport": "개별 가져오기",
"batchImport": "일괄 가져오기",
"batchUrlInputTip": "한 줄에 URL 하나씩",
"batchLinkInputTip": "한 줄에 링크 하나씩",
"batchPreviewTip": "추가 {count}개, 이미 있어서 건너뜀 {skipped}개",
"lineIssueTip": "{line}행: {message}",
"singleValueTip": "{label}은(는) 하나만 입력해야 합니다",
"deleteTip": "이 {label}을(를) 삭제하시겠습니까?",
"deleteMultipTip": "선택한 {label}을(를) 삭제하시겠습니까?",
"nullTip": "{label}이(가) 아직 없습니다",
"script": "스크립트",
"color": "색상",
"unnamed": "이름 없음",
"pleaseEnterScriptName": "스크립트 이름을 입력하세요",
"mixedPort": "혼합 포트",
"socksPort": "SOCKS 포트",
"redirPort": "Redir 포트",
"tproxyPort": "TProxy 포트",
"portTip": "{label}은(는) 1024에서 49151 사이여야 합니다",
"portConflictTip": "다른 포트를 입력하세요",
"import": "가져오기",
"importFile": "파일에서 가져오기",
"importUrl": "URL에서 가져오기",
"startFromScratch": "처음부터 시작",
"autoSetSystemDns": "시스템 DNS 자동 설정",
"details": "{label} 상세 정보",
"creationTime": "생성 시간",
"process": "프로세스",
"host": "호스트",
"destination": "목적지",
"destinationGeoIP": "목적지 GeoIP",
"destinationIPASN": "목적지 IP ASN",
"specialProxy": "특수 프록시",
"specialRules": "특수 규칙",
"remoteDestination": "원격 목적지",
"networkType": "네트워크 유형",
"proxyChains": "프록시 체인",
"log": "로그",
"connection": "연결",
"request": "요청",
"connected": "연결됨",
"disconnected": "연결 끊김",
"connecting": "연결 중…",
"restartCoreTip": "코어를 다시 시작하시겠습니까?",
"forceRestartCoreTip": "코어를 강제로 다시 시작하시겠습니까?",
"dnsHijacking": "DNS 하이재킹",
"coreStatus": "코어 상태",
"appendSystemDns": "시스템 DNS 추가",
"editRule": "규칙 편집",
"extendMode": "확장 모드",
"standardModeDesc": "표준 모드: 프로필 위에 규칙을 추가합니다",
"scriptModeDesc": "스크립트 모드: 확장 스크립트로 프로필 전체를 다시 작성합니다",
"addedRules": "추가한 규칙",
"controlGlobalAddedRules": "전역 추가 규칙 관리",
"quickActions": "빠른 작업",
"rulePresetBlockQuic": "QUIC 차단",
"rulePresetBlockStun": "STUN 차단",
"rulePresetBlockDot": "DNS over TLS 차단",
"rulePresetBlockLoopback": "프록시 루프백 차단",
"rulePresetSystemServicesDirect": "Apple 및 Microsoft 직접 연결",
"rulePresetBittorrentDirect": "BitTorrent 직접 연결",
"rulePresetLanDirect": "LAN 직접 연결",
"extendScript": "확장 스크립트",
"goToConfigureScript": "스크립트 구성으로 이동",
"goToConfigureAddedRules": "추가한 규칙 구성으로 이동",
"editGlobalRules": "전역 규칙 편집",
"confirmForceCrashCore": "코어를 강제로 비정상 종료시키시겠습니까?",
"confirmClearAllData": "모든 데이터를 지우시겠습니까?",
"loading": "로드 중…",
"yearsAgo": "{count, plural, other{{count}년 전}}",
"monthsAgo": "{count, plural, other{{count}개월 전}}",
"daysAgo": "{count, plural, other{{count}일 전}}",
"hoursAgo": "{count, plural, other{{count}시간 전}}",
"minutesAgo": "{count, plural, other{{count}분 전}}",
"justNow": "방금",
"yearsAgoShort": "{count, plural, other{{count}년 전}}",
"monthsAgoShort": "{count, plural, other{{count}개월 전}}",
"daysAgoShort": "{count, plural, other{{count}일 전}}",
"hoursAgoShort": "{count, plural, other{{count}시간 전}}",
"minutesAgoShort": "{count, plural, other{{count}분 전}}",
"noLongerRemind": "다시 알리지 않음",
"accessControlSettings": "접근 제어 설정",
"turnOn": "켜기",
"turnOff": "끄기",
"vpnConfigChangeDetected": "VPN 관련 구성 변경이 감지되었습니다",
"restart": "다시 시작",
"speedStatistics": "속도 통계",
"resetPageChangesTip": "이 페이지에 변경 사항이 있습니다. 초기화하시겠습니까?",
"customProfile": "사용자 지정",
"customProfileDesc": "앱 프록시와 다른 프로필로 프록시 그룹과 규칙을 조합합니다",
"customProfileResources": "사용자 지정 프로필 리소스",
"customProfileResourcesDesc": "사용자 지정 프로필 리소스에는 프록시, 규칙 공급자, 필터, 아이콘 세트가 포함됩니다.",
"unknownNetworkError": "알 수 없는 네트워크 오류",
"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": "백업 및 복원",
"restore": "복원",
"restoreSuccess": "복원 완료",
"restoreOnlyConfig": "프로필만 복원",
"restoreAllData": "모든 데이터 복원",
"addProfile": "프로필 추가",
"delayTest": "지연 테스트",
"proxyGroupEmpty": "프록시 그룹이 비어 있습니다",
"confirmExitWindow": "현재 창을 닫으시겠습니까?",
"optional": "선택 사항",
"maxFailedTimes": "최대 실패 횟수",
"testInterval": "테스트 간격",
"excludeFilter": "제외 필터",
"expectedStatus": "예상 상태",
"strategy": "전략",
"tolerance": "허용 오차",
"inputProxyGroupName": "프록시 그룹 이름을 입력하세요",
"helperCorruptTip": "헬퍼 서비스를 사용할 수 없어 TUN 모드를 켤 수 없습니다. FlClash를 다시 설치하여 복구하세요.",
"tunAuthorizationFailed": "TUN 권한을 얻지 못해 끄기로 되돌렸습니다.",
"sudoPasswordTitle": "sudo 비밀번호를 입력하세요",
"coreBlockedByPolicyTip": "Windows에서 FlClashCore.exe 실행을 거부했습니다(오류 {code}). Smart App Control이나 AppLocker와 같은 앱 제어 정책이 서명되지 않은 프로그램을 차단합니다. 해당 정책에서 FlClash를 허용하거나 정책을 끈 후 다시 시도하세요.",
"coreBlockedBySmartAppControlTip": "서명되지 않아 Windows Smart App Control이 FlClashCore.exe를 차단했습니다. Windows 보안 → 앱 및 브라우저 컨트롤 → Smart App Control 설정에서 끔을 선택한 후 FlClash를 다시 시작하세요. Smart App Control은 Windows를 다시 설치하지 않으면 다시 켤 수 없습니다.",
"hideFromList": "목록에서 숨기기",
"testWhenUsed": "사용할 때 테스트",
"disableUDP": "UDP 사용 안 함",
"confirmDeleteProxyGroup": "이 프록시 그룹을 삭제하시겠습니까?",
"ruleEmpty": "규칙이 비어 있습니다",
"inputRuleContent": "규칙 내용을 입력하세요",
"ruleSet": "규칙 세트",
"selectRuleSet": "규칙 세트를 선택하세요",
"splitStrategy": "분할 전략",
"selectSplitStrategy": "분할 전략을 선택하세요",
"noResolveHostname": "호스트 이름 확인 안 함",
"matchSourceIp": "출발지 IP 일치",
"basicInfo": "기본 정보",
"additionalParameters": "추가 매개변수",
"proxyType": "프록시 유형",
"basicStrategy": "기본 전략",
"editProxy": "프록시 편집",
"addNodes": "노드 추가",
"addProxyGroup": "프록시 그룹 추가",
"editProxyGroup": "프록시 그룹 편집",
"iconUrl": "아이콘 URL",
"iconRecords": "아이콘 기록",
"custom": "사용자 지정",
"ruleActionDomainDesc": "전체 도메인 일치",
"ruleActionDomainSuffixDesc": "도메인 접미사 일치",
"ruleActionDomainKeywordDesc": "도메인 키워드 일치",
"ruleActionDomainRegexDesc": "도메인 정규식 일치",
"ruleActionDomainWildcardDesc": "와일드카드 일치, *와 ?만 지원",
"ruleActionGeositeDesc": "Geosite의 도메인 일치",
"ruleActionIpCidrDesc": "IP 주소 범위 일치",
"ruleActionIpCidr6Desc": "IP 주소 범위 일치, IP-CIDR6는 별칭일 뿐입니다",
"ruleActionIpSuffixDesc": "IP 접미사 범위 일치",
"ruleActionIpAsnDesc": "IP의 ASN 일치",
"ruleActionGeoipDesc": "IP의 국가 코드 일치",
"ruleActionSrcGeoipDesc": "출발지 IP의 국가 코드 일치",
"ruleActionSrcIpAsnDesc": "출발지 IP의 ASN 일치",
"ruleActionSrcIpCidrDesc": "출발지 IP 주소 범위 일치",
"ruleActionSrcIpSuffixDesc": "출발지 IP 접미사 범위 일치",
"ruleActionDstPortDesc": "목적지 포트 범위 일치",
"ruleActionSrcPortDesc": "출발지 포트 범위 일치",
"ruleActionInPortDesc": "인바운드 포트 일치",
"ruleActionInTypeDesc": "인바운드 유형 일치",
"ruleActionInUserDesc": "인바운드 사용자 이름 일치, 여러 사용자 이름은 /로 구분",
"ruleActionInNameDesc": "인바운드 이름 일치",
"ruleActionRematchNameDesc": "리매치 이름 일치, 여러 이름은 /로 구분",
"ruleActionProcessPathDesc": "전체 프로세스 경로로 일치",
"ruleActionProcessPathRegexDesc": "프로세스 경로 정규식으로 일치",
"ruleActionProcessPathWildcardDesc": "프로세스 경로 와일드카드로 일치, *와 ?만 지원",
"ruleActionProcessNameDesc": "프로세스 이름으로 일치, Android에서는 패키지 이름과 일치",
"ruleActionProcessNameRegexDesc": "프로세스 이름 정규식으로 일치, Android에서는 패키지 이름과 일치",
"ruleActionProcessNameWildcardDesc": "프로세스 이름 와일드카드로 일치, *와 ?만 지원",
"ruleActionUidDesc": "Linux 사용자 ID 일치",
"ruleActionNetworkDesc": "TCP 또는 UDP 일치",
"ruleActionDscpDesc": "DSCP 마크 일치(tproxy UDP 인바운드 전용)",
"ruleActionRuleSetDesc": "규칙 세트 참조, rule-providers 필요",
"ruleActionAndDesc": "논리 규칙 AND",
"ruleActionOrDesc": "논리 규칙 OR",
"ruleActionNotDesc": "논리 규칙 NOT",
"ruleActionSubRuleDesc": "하위 규칙으로 일치, 괄호에 유의",
"ruleActionMatchDesc": "모든 요청과 일치, 조건 불필요",
"contentNotEmpty": "내용을 입력하세요",
"splitStrategyNotEmpty": "분할 전략을 입력하세요",
"timeout": "시간 초과",
"delayFailed": "실패",
"invalidSubRule": "올바르지 않은 SUB_RULE: {subRule}",
"invalidPolicy": "올바르지 않은 정책: {target}",
"invalidProxyProvider": "올바르지 않은 프록시 공급자: {providerName}",
"invalidRuleSet": "올바르지 않은 규칙 세트: {ruleSet}",
"invalidNetworkContent": "tcp 또는 udp만 지원합니다",
"invalidRangeContent": "80 또는 8000-9000과 같은 숫자나 범위를 /로 구분하여 입력하세요",
"invalidDscpContent": "DSCP 마크는 63을 넘을 수 없습니다",
"invalidCidrContent": "192.168.0.0/16과 같은 CIDR 형식의 IP 범위를 입력하세요",
"invalidPortRangeContent": "443 또는 8000-9000과 같은 포트나 범위를 입력하세요",
"invalidHostContent": "도메인 또는 IP 주소를 입력하세요",
"invalidListenContent": "0.0.0.0:1053과 같은 주소와 포트를 입력하세요",
"invalidProxy": "올바르지 않은 프록시: {proxyName}",
"locationPermissionRequired": "위치 권한 필요",
"locationPermissionGuide": "1. 시스템 설정 > 개인정보 보호 및 보안을 엽니다\n2. 위치 서비스를 선택합니다\n3. 목록에서 {appName}을(를) 찾아 체크합니다\n\n완료되면 앱으로 돌아와 계속하세요. 협조해 주셔서 감사합니다.",
"prerequisites": "사전 요건",
"ignoreBatteryOptimization": "배터리 최적화 무시",
"batteryOptimizationDesc": "배터리 최적화를 무시하면 앱이 백그라운드에서 계속 실행됩니다.",
"locationPermission": "위치 권한",
"locationPermissionDesc": "시스템에서 Wi-Fi 이름을 읽으려면 위치 권한이 필요합니다. Android에서는 “항상 허용”을 선택해야 하며, 그렇지 않으면 앱이 백그라운드에 있을 때 Wi-Fi 이름을 읽을 수 없습니다.",
"excludeSsids": "제외할 SSID",
"excludeSsidsDesc": "제외 SSID의 Wi-Fi에 연결되면 앱의 실행 상태가 자동으로 전환됩니다.",
"ssidsEmpty": "SSID가 비어 있습니다",
"onDemand": "필요 시 실행",
"onDemandDesc": "필요 시 실행은 제외된 Wi-Fi에서 프록시를 일시 중지합니다.",
"locationPermissionDeniedMessage": "위치 권한이 거부되어 현재 Wi-Fi 이름을 읽을 수 없습니다. 시스템 설정에서 위치 권한을 직접 켜 주세요.",
"addSsid": "SSID 추가",
"editSsid": "SSID 편집",
"authorized": "허용됨",
"tapToAuthorize": "눌러서 허용",
"suspended": "일시 중지됨…",
"resourceUpdateInterval": "자동 업데이트 간격",
"resourceUpdateIntervalTip": "자동 업데이트 간격은 0보다 커야 합니다",
"hours": "시간",
"minutes": "분",
"geoResources": "Geo",
"geoSkipped": "{name}: 이미 최신 상태입니다",
"geoUpdated": "{name}: 업데이트됨",
"secondsCount": "{count, plural, other{{count}초}}",
"proxiesCount": "{count, plural, other{프록시 {label}개}}",
"rulesCount": "{count, plural, other{규칙 {label}개}}",
"proxyGroupsCount": "{count, plural, other{프록시 그룹 {label}개}}",
"changelogBreaking": "호환성 변경",
"changelogFeatures": "새로운 기능",
"changelogFixes": "버그 수정",
"changelogPerformance": "성능",
"changelogReverts": "되돌림",
"close": "닫기",
"back": "뒤로",
"minimize": "최소화",
"maximize": "최대화",
"unmaximize": "이전 크기로 복원",
"pinWindow": "창 고정",
"unpinWindow": "창 고정 해제",
"exitFullScreen": "전체 화면 종료",
"torch": "손전등",
"pickFromAlbum": "앨범에서 선택",
"blockConnection": "연결 차단",
"closeConnections": "연결 종료",
"scrollToSelected": "선택 항목으로 스크롤",
"showMore": "펼치기",
"showLess": "접기",
"previousMatch": "이전 결과",
"nextMatch": "다음 결과",
"clearSearch": "검색 지우기",
"noSearchResults": "일치하는 결과 없음",
"showPassword": "비밀번호 표시",
"hidePassword": "비밀번호 숨기기",
"authorize": "허용",
"installedAppsPermissionRequired": "앱 목록 권한 필요",
"installedAppsPermissionDesc": "이 시스템은 권한을 허용하기 전까지 설치된 앱 목록을 숨깁니다. 앱별 프록시를 구성하려면 권한을 허용하세요.",
"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": "캐시",
"yes": "예",
"no": "아니요",
"nonTextProviderFile": "이 외부 리소스는 텍스트 파일이 아닙니다",
"addCustomProxy": "프록시 추가",
"definitionNotMap": "구성은 이름과 유형이 있는 YAML 매핑이어야 합니다",
"ruleTextInvalid": "규칙은 DOMAIN,example.com,DIRECT처럼 지원하는 유형으로 시작해야 합니다",
"ruleListInvalid": "규칙은 항목마다 규칙 하나인 YAML 목록이어야 합니다. 예: - DOMAIN,example.com,DIRECT",
"shareLinksInvalid": "링크에서 읽을 수 있는 프록시가 없습니다",
"shareLinkUnreadable": "읽을 수 있는 프록시가 없습니다: {link}",
"proxiesProfileLabel": "{name} 외 (프록시 {count}개)",
"singleShareLinkOnly": "여기에는 링크를 하나만 입력하세요. 여러 개는 목록의 빠른 편집에서 편집하세요",
"username": "사용자 이름",
"emptyFallback": "빈 폴백",
"defaultSelected": "기본 선택",
"hashByInUser": "인바운드 사용자별 해시",
"appProxiesEmpty": "로컬 프록시 없음",
"nodes": "노드",
"localProxies": "로컬 프록시",
"customIssueEmptyName": "이름이 비어 있습니다",
"customIssueReservedName": "내장 정책 이름이라 여기에서 사용할 수 없습니다: {name}",
"customIssueDuplicateName": "다른 프록시 또는 프록시 그룹이 이미 사용 중인 이름입니다: {name}",
"customIssueCoreRejected": "코어가 이 프록시를 해석할 수 없습니다: {message}",
"customIssueMissingProxies": "존재하지 않는 프록시 또는 정책: {names}",
"customIssueMissingProviders": "존재하지 않는 프록시 공급자: {names}",
"customIssueNoProxySource": "선택한 프록시나 프록시 공급자가 없어 코어가 이 그룹을 거부합니다",
"customIssueGroupLoop": "프록시 그룹이 서로를 순환 참조합니다: {path}",
"customIssueInvalidEmptyFallback": "빈 폴백은 프록시여야 하지만 존재하지 않거나 프록시 그룹입니다: {name}",
"customIssueInvalidFilter": "올바른 정규식이 아닙니다({name}): {message}",
"customIssueMissingDialer": "존재하지 않는 다이얼러 프록시: {name}",
"customIssueDialerLoop": "{proxy}이(가) {target}을(를) 거쳐 다시 {proxy}(으)로 돌아오므로 다이얼이 순환합니다",
"dialerProxyLoopStopped": "{name}을(를) 거치는 다이얼이 이를 사용하는 프록시로 되돌아와 연결을 중단했습니다",
"customIssuesSummary": "문제가 있는 항목 {count}개, 이 프로필을 적용하면 실패할 수 있습니다",
"providerInUse": "{label}은(는) 아직 {profiles}에서 사용 중이며, 삭제하면 해당 프로필이 제대로 작동하지 않습니다. 삭제하시겠습니까?",
"proxyGroupInUse": "{label}은(는) 이 프로필의 규칙, 프록시 그룹, 다이얼러 프록시, DNS 또는 NTP에서 아직 참조 중이며, 삭제하면 작동하지 않게 됩니다. 삭제하시겠습니까?",
"proxiesRemovedTip": "다음 프록시가 삭제됩니다: {removed}. 이름이 바뀐 줄은 새 프록시로 간주되며, 삭제된 프록시를 지정한 사용자 지정 프로필에는 누락으로 표시됩니다. 그래도 적용하시겠습니까?",
"proxiesReplacedTip": "다음 프록시가 삭제됩니다: {removed}. 다음 프록시가 추가됩니다: {added}. 이름이 바뀐 줄은 새 프록시로 간주되며, 삭제된 프록시를 지정한 사용자 지정 프로필에는 누락으로 표시됩니다. 그래도 적용하시겠습니까?",
"fontSize": "크기",
"large": "크게",
"extraLarge": "매우 크게",
"switchProfile": "프로필 전환",
"lastUpdated": "마지막 업데이트",
"filters": "필터",
"label": "레이블",
"regex": "정규식",
"matchCase": "대/소문자 구분",
"iconSets": "아이콘 세트",
"invalidIconSet": "올바른 아이콘 세트가 아닙니다",
"recommendedIcons": "추천",
"localImage": "로컬 이미지",
"iconSetsEmptyTip": "아이콘 세트가 아직 없습니다. 사용자 지정 프로필 리소스 → 아이콘 세트에서 추가하세요",
"outbound": "아웃바운드",
"strictRoute": "엄격한 라우팅",
"congestionController": "TCP 혼잡 제어",
"congestionControllerDesc": "TCP 혼잡 제어는 mips 스택에서만 작동합니다.",
"routeExcludeAddress": "라우팅 제외 주소",
"keepAliveIdle": "TCP 킵얼라이브 유휴 시간",
"disableKeepAlive": "TCP 킵얼라이브 사용 안 함",
"routingMark": "라우팅 마크",
"strictRouteDesc": "엄격한 라우팅은 DNS 등의 트래픽이 TUN을 우회하지 못하게 하며, 다른 기기는 이 기기에 접속할 수 없게 됩니다.",
"initFailed": "시작 실패",
"initFailedTip": "FlClash를 시작하는 중 오류가 발생하여 계속할 수 없습니다. 세부 정보를 복사하여 문제를 보고하세요. 문제가 계속되면 데이터를 지우면 대개 앱이 다시 시작됩니다.",
"errorDetails": "오류 세부 정보",
"stackTrace": "스택 트레이스",
"clearDataAndExitTip": "모든 프로필, 설정, 로컬 데이터를 삭제한 후 앱을 종료합니다. 앱을 다시 열면 처음부터 시작합니다.",
"clearDataFailed": "데이터를 지우지 못했습니다: {detail}",
"bypassPrivateRoute": "사설 주소 우회",
"bypassPrivateRouteDesc": "사설 주소 우회를 켜면 LAN, 루프백, 멀티캐스트 주소는 TUN을 거치지 않습니다.",
"disableIcmpForwarding": "ICMP 전달 사용 안 함",
"disableIcmpForwardingDesc": "ICMP 전달을 끄면 TUN이 ping에 직접 응답하므로 ping에 실제 지연 시간이 표시되지 않습니다.",
"excludeInterface": "제외할 인터페이스",
"excludeInterfaceDesc": "docker0와 같이 제외할 인터페이스로 들어오는 트래픽은 TUN을 거치지 않습니다.",
"includeInterface": "포함할 인터페이스",
"includeInterfaceDesc": "포함할 인터페이스로 들어오는 트래픽만 TUN을 거치며, 제외할 인터페이스는 더 이상 적용되지 않습니다.",
"includeUid": "포함할 사용자 ID",
"includeUidDesc": "포함할 사용자 ID의 프로세스 트래픽만 TUN을 거칩니다.",
"excludeUid": "제외할 사용자 ID",
"excludeUidDesc": "제외할 사용자 ID의 프로세스 트래픽은 TUN을 거치지 않습니다.",
"excludeSrcPort": "제외할 소스 포트",
"excludeSrcPortDesc": "제외할 소스 포트에서 나가는 트래픽은 TUN을 거치지 않습니다.",
"excludeDstPort": "제외할 대상 포트",
"excludeDstPortDesc": "제외할 대상 포트로 가는 트래픽은 TUN을 거치지 않습니다.",
"udpTimeout": "UDP 시간 제한",
"udpTimeoutDesc": "UDP 시간 제한은 초 단위이며 gvisor, system, mixed 스택에서만 작동합니다.",
"dnsHijackDesc": "추가하지 않으면 53번 포트로 가는 모든 DNS 쿼리를 하이재킹합니다(any:53).",
"tunOverrideDesc": "여기에 추가한 항목은 모든 프로필의 TUN 설정을 대체합니다. 추가하지 않은 항목은 프로필 자체 설정을 유지합니다. 사용자 지정 프로필이 직접 추가한 항목이 우선합니다.",
"profileTunSettingsDesc": "이 프로필에만 적용되며 네트워크 설정의 TUN 덮어쓰기 항목보다 우선합니다.",
"settingsCount": "{count, plural, other{설정 {count}개}}",
"sectionsInEffect": "{count}개 적용 중",
"itemsCount": "{count, plural, other{항목 {count}개}}",
"additionalPrefix": "이름 접두사",
"additionalSuffix": "이름 접미사",
"skipCertVerify": "인증서 검증 건너뛰기",
"ipVersion": "IP 버전",
"addFilters": "필터 추가",
"failedItem": "{label}: {message}",
"failedCount": "{count}개 항목 실패",
"importConfigInvalid": "가져올 수 있는 구성 파일이 아닙니다",
"importConfigReplaceTip": "가져오면 이 프로필의 현재 프록시 그룹, 규칙, 설정이 대체됩니다",
"networkName": "네트워크 이름",
"networkSecret": "네트워크 시크릿",
"peers": "피어",
"virtualIpv4": "가상 IPv4",
"hostname": "호스트 이름",
"exitNodes": "출구 노드",
"cipher": "암호화 방식",
"protocol": "프로토콜",
"protocolParam": "프로토콜 매개변수",
"obfs": "난독화",
"obfsParam": "난독화 매개변수",
"obfsPassword": "난독화 비밀번호",
"obfsMode": "난독화 모드",
"obfsHost": "난독화 호스트",
"plugin": "플러그인",
"pluginMode": "플러그인 모드",
"pluginHost": "플러그인 호스트",
"privateKey": "개인 키",
"publicKey": "공개 키",
"privateKeyPassphrase": "개인 키 암호",
"preSharedKey": "사전 공유 키",
"secretKey": "키",
"transport": "전송",
"uploadBandwidth": "업로드 대역폭",
"downloadBandwidth": "다운로드 대역폭",
"protocolVersion": "프로토콜 버전",
"portHopping": "포트 호핑",
"hopInterval": "호핑 간격",
"congestionControl": "혼잡 제어",
"udpRelayMode": "UDP 릴레이 모드",
"allowedIps": "허용 IP",
"remoteDnsResolve": "원격 DNS 확인",
"clientFingerprint": "클라이언트 핑거프린트",
"required": "필수",
"ageSecretKey": "age 비밀 키",
"ageEncryptedProfile": "암호화된 프로필",
"ageSecretKeyRequiredTip": "이 프로필은 age로 암호화되어 있어 복호화하려면 age 비밀 키가 필요합니다",
"currentOutboundDesc": "시작 버튼이 있어 제거할 수 없습니다",
"running": "실행 중",
"notRunning": "실행 중 아님",
"trafficPeak": "최고 {value}",
"trafficTotal": "합계 {value}",
"ipQualityShort": "품질",
"ipTypeUnknown": "알 수 없는 유형",
"tapIconToRetry": "아이콘을 눌러 다시 시도",
"networkTakeover": "트래픽 가로채기",
"portValue": "포트 {port}",
"serviceAbnormalCount": "{count, plural, other{문제 {count}건}}",
"serviceAllAvailable": "모두 사용 가능",
"serviceShownOnDashboard": "대시보드에 표시됨",
"groupTimeoutCount": "{count}개 시간 초과",
"groupCountUnit": "개",
"groupCount": "{count, plural, other{그룹 {count}개}}",
"proxyGroupsAfterStart": "코어가 시작되면 그룹이 여기에 표시됩니다",
"usedShort": "사용",
"profileUpdatedAt": "{time} 업데이트",
"profileExpiresIn": "{date} 만료, {days, plural, other{{days}일}} 남음",
"profileDaysLeft": "{days, plural, other{{days}일 남음}}",
"target": "대상",
"outboundExit": "출구",
"duration": "소요 시간",
"traffic": "트래픽",
"totalDownload": "다운로드량",
"totalUpload": "업로드량",
"initiatorShort": "출처",
"result": "결과",
"elapsed": "소요",
"connectionStats": "연결 통계",
"connectionStatsDesc": "실시간 연결, 최근 요청, DNS 쿼리",
"memoryInfoDesc": "앱과 코어가 사용하는 메모리",
"editDashboard": "대시보드 편집",
"done": "완료",
"dashboardAddableSection": "추가 가능",
"customImportDroppedSubRules": "{count, plural, other{사용자 지정 프로필은 하위 규칙을 지원하지 않아 SUB-RULE 규칙 {label}개를 제외했습니다}}",
"autoInclude": "자동 포함",
"allReferencedProxies": "참조된 모든 프록시",
"allReferencedProxyProviders": "참조된 모든 프록시 공급자"
}
+88 -54
View File
@@ -13,7 +13,6 @@
"trafficUsage": "Статистика трафика",
"networkSpeed": "Скорость сети",
"outboundMode": "Режим исходящего трафика",
"networkDetection": "Проверка сети",
"serviceStatus": "Состояние сервисов",
"serviceRestricted": "Доступ ограничен",
"serviceAvailable": "Доступен",
@@ -42,13 +41,15 @@
"other": "Другое",
"about": "О программе",
"en": "Английский",
"es": "Испанский",
"fr": "Французский",
"ja": "Японский",
"ko": "Корейский",
"ru": "Русский",
"zhCN": "Упрощённый китайский",
"theme": "Тема",
"themeDesc": "Тёмный режим и настройка цветов",
"extend": "Расширение",
"finalConfig": "Итоговая конфигурация",
"runtimeConfig": "Конфигурация выполнения",
"allowLan": "Разрешить LAN",
"tun": "TUN",
"tunDesc": "TUN работает только в режиме администратора.",
@@ -64,7 +65,7 @@
"checkCertificate": "Проверять TLS-сертификаты",
"checkCertificateDesc": "Без проверки TLS-сертификатов подписки и резервные копии уязвимы для атаки «человек посередине».",
"accessControl": "Контроль доступа",
"accessControlDesc": "Выбор приложений, использующих прокси",
"accessControlDesc": "Контроль доступа выбирает приложения, использующие прокси.",
"edit": "Редактировать",
"confirm": "Подтвердить",
"update": "Обновить",
@@ -85,7 +86,7 @@
"profileUrlNullValidationDesc": "Введите URL профиля",
"profileUrlInvalidValidationDesc": "Введите корректный URL профиля",
"autoUpdate": "Автообновление",
"autoUpdateInterval": "Интервал автообновления (минуты)",
"autoUpdateInterval": "Интервал автообновления",
"profileAutoUpdateIntervalNullValidationDesc": "Введите интервал автообновления",
"profileAutoUpdateIntervalInvalidValidationDesc": "Введите корректный интервал",
"themeMode": "Режим темы",
@@ -180,9 +181,7 @@
"testUrl": "URL для теста",
"sync": "Синхронизация",
"exclude": "Скрыть из недавних задач",
"expand": "Стандартный",
"shrink": "Компактный",
"min": "Минимальный",
"compact": "Компактный",
"tab": "Вкладки",
"list": "Список",
"delay": "Задержка",
@@ -202,6 +201,7 @@
"pureBlackMode": "Чисто чёрный режим",
"sidebarBlur": "Размытие боковой панели",
"sidebarBlurDesc": "Показывать сквозь боковую панель размытый рабочий стол за окном",
"liquidGlass": "Жидкое стекло",
"keepAliveIntervalDesc": "Интервал TCP keep-alive",
"entries": " записей",
"local": "Локально",
@@ -216,6 +216,7 @@
"layout": "Макет",
"tight": "Плотный",
"standard": "Стандартный",
"ultra": "Максимум",
"loose": "Свободный",
"profilesSort": "Сортировка профилей",
"start": "Старт",
@@ -223,7 +224,6 @@
"stop": "Стоп",
"key": "Ключ",
"value": "Значение",
"vpnTip": "Изменения вступят в силу после перезапуска VPN",
"vpnEnableDesc": "VPN автоматически направляет весь системный трафик через VpnService.",
"options": "Опции",
"enabled": "Включено",
@@ -316,26 +316,20 @@
"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 по умолчанию.",
"disclaimerPrivacyContent": "Программа не собирает и не отправляет адреса ваших подписок, сведения об узлах, содержимое конфигураций, посещённые сайты, записи о подключениях, содержимое трафика и журналы. Эти данные хранятся только на вашем устройстве, и у разработчиков нет к ним доступа.\n\nПрограмма обращается к сети только при использовании соответствующих функций, например загружает указанный вами адрес подписки при обновлении профиля или обращается к GitHub при проверке обновлений.\n\nПрограмма не содержит никаких сервисов статистики или отчётов о сбоях ни на одной платформе.",
"disclaimerLicenseTitle": "Лицензия с открытым исходным кодом",
"disclaimerLicenseContent": "Программа распространяется с открытым исходным кодом по лицензии GPL-3.0. Вы можете свободно использовать, изменять и распространять её при соблюдении этой лицензии: производные работы также должны распространяться по GPL-3.0 с сохранением уведомлений об авторских правах.\n\nСторонние компоненты Программы, включая ядро Clash.Meta, распространяются по своим собственным лицензиям. Авторы оригинала не несут ответственности за проблемы, возникшие в изменённых или распространяемых третьими лицами версиях.",
"disclaimerChangesTitle": "Изменения заявления",
"disclaimerChangesContent": "Разработчики могут изменять это заявление в любом выпуске; изменения вступают в силу с момента публикации выпуска. Продолжая пользоваться Программой после обновления, вы принимаете изменённое заявление.",
"disclaimerAcceptTitle": "Принятие условий",
"disclaimerAcceptContent": "Устанавливая, копируя или используя Программу, вы подтверждаете, что прочитали и приняли это заявление полностью. Если вы не согласны с каким-либо его условием, немедленно прекратите использование и удалите Программу.",
"disclaimerAndroidOnly": "Только Android",
"disclaimerGooglePrivacy": "Политика конфиденциальности Google",
"disclaimerFirebasePrivacy": "Конфиденциальность и безопасность Firebase",
"disclaimerReadToEnd": "Дочитайте до конца",
"disclaimerRestateTitle": "Повторите заявление",
"disclaimerRestateTip": "Чтобы подтвердить, что вы ознакомились с ним, полностью введите следующее заявление в поле ниже:",
"disclaimerRestateHint": "Введите заявление выше",
"disclaimerRestateMismatch": "Не совпадает с текстом заявления",
"disclaimerAcknowledgeTitle": "Подтвердите заявление",
"disclaimerAcknowledgeTip": "Чтобы подтвердить, что вы ознакомились с ним, прочитайте следующее заявление и отметьте пункт ниже:",
"disclaimerAcknowledgeLabel": "Я прочитал(а) и понимаю это заявление",
"agree": "Согласен",
"hotkeyManagement": "Горячие клавиши",
"hotkeyDesc": "Глобальные горячие клавиши работают, даже когда окно скрыто. Нажмите на действие, чтобы записать сочетание клавиш.",
@@ -405,12 +399,9 @@
"lineWrap": "Перенос строк",
"none": "Нет",
"advancedConfig": "Расширенная конфигурация",
"advancedConfigDesc": "Сеть, DNS, добавленные правила и скрипты",
"selectedCountTitle": "Выбрано: {count}",
"advancedConfigDesc": "Расширенная конфигурация включает сеть, DNS, добавленные правила и скрипты.",
"addRule": "Добавить правило",
"ruleName": "Название правила",
"content": "Содержимое",
"subRule": "Подправило",
"ruleTarget": "Цель правила",
"matchTarget": "MATCH-TARGET",
"followProfile": "Как в профиле",
@@ -424,17 +415,19 @@
"slide": "Сдвиг",
"fade": "Растворение",
"pureBlack": "Чисто чёрный",
"colorSchemes": "Цветовые схемы",
"palette": "Палитра",
"tonalSpotScheme": "Тональный акцент",
"fidelityScheme": "Точная передача",
"customColor": "Свой цвет",
"colorStyle": "Стиль палитры",
"grid": "Сетка",
"spectrum": "Спектр",
"sliders": "Ползунки",
"hue": "Оттенок",
"saturation": "Насыщенность",
"brightness": "Яркость",
"hex": "Hex",
"monochromeScheme": "Монохром",
"neutralScheme": "Нейтральная",
"vibrantScheme": "Яркая",
"expressiveScheme": "Экспрессивная",
"contentScheme": "Контентная",
"rainbowScheme": "Радуга",
"fruitSaladScheme": "Фруктовый микс",
"vividStyle": "Яркий",
"tintedStyle": "Тонированный",
"softStyle": "Мягкий",
"developerMode": "Режим разработчика",
"developerModeEnableTip": "Режим разработчика включён.",
"messageTest": "Тест сообщения",
@@ -514,10 +507,6 @@
"forceRestartCoreTip": "Вы уверены, что хотите принудительно перезапустить ядро?",
"dnsHijacking": "Перехват DNS",
"coreStatus": "Статус ядра",
"dataCollectionTip": "Уведомление о сборе данных",
"dataCollectionContent": "Это приложение использует Firebase Crashlytics для сбора информации о сбоях, чтобы повысить стабильность.\nСобираемые данные включают сведения об устройстве и подробности сбоя и не содержат личных конфиденциальных данных.\nЭту функцию можно отключить в настройках.",
"crashlytics": "Аналитика сбоев",
"crashlyticsTip": "Аналитика сбоев при сбое загружает лог без конфиденциальной информации.",
"appendSystemDns": "Добавлять системный DNS",
"editRule": "Редактировать правило",
"extendMode": "Режим расширения",
@@ -562,7 +551,7 @@
"customProfile": "Пользовательский",
"customProfileDesc": "Группы прокси и правила из прокси приложения и других профилей",
"customProfileResources": "Ресурсы пользовательских профилей",
"customProfileResourcesDesc": "Прокси, провайдеры правил, фильтры и наборы значков",
"customProfileResourcesDesc": "Ресурсы пользовательских профилей включают прокси, провайдеры правил, фильтры и наборы значков.",
"unknownNetworkError": "Неизвестная сетевая ошибка",
"backupFromNewerVersion": "Резервная копия создана более новой версией приложения. Обновите приложение перед восстановлением",
"networkTimeoutError": "Время ожидания запроса истекло. Проверьте сеть или прокси и повторите попытку",
@@ -579,7 +568,6 @@
"invalidBackupFile": "Недопустимый файл резервной копии",
"pruneCache": "Очистить кэш",
"backupAndRestore": "Резервное копирование и восстановление",
"backupAndRestoreDesc": "Синхронизация данных через WebDAV или файлы",
"restore": "Восстановить",
"restoreSuccess": "Восстановление выполнено",
"restoreOnlyConfig": "Восстановить только профили",
@@ -587,9 +575,7 @@
"addProfile": "Добавить профиль",
"delayTest": "Тест задержки",
"proxyGroupEmpty": "Группа прокси пуста",
"proxyGroupNameDuplicate": "Название группы прокси уже используется",
"confirmExitWindow": "Вы уверены, что хотите закрыть текущее окно?",
"selectProxyProviders": "Выбрать провайдеров прокси",
"optional": "Необязательно",
"maxFailedTimes": "Макс. число неудач",
"testInterval": "Интервал тестирования",
@@ -597,7 +583,6 @@
"expectedStatus": "Ожидаемый статус",
"strategy": "Стратегия",
"tolerance": "Допуск",
"selectProxies": "Выбрать прокси",
"inputProxyGroupName": "Введите название группы прокси",
"helperCorruptTip": "Служба Helper недоступна, поэтому TUN-режим включить нельзя. Переустановите FlClash.",
"tunAuthorizationFailed": "Не удалось получить права для TUN, поэтому он выключен.",
@@ -614,7 +599,6 @@
"selectRuleSet": "Выберите набор правил",
"splitStrategy": "Стратегия распределения",
"selectSplitStrategy": "Выберите стратегию распределения",
"selectSubRule": "Выберите подправило",
"noResolveHostname": "Не разрешать имя хоста",
"matchSourceIp": "Сопоставлять IP источника",
"basicInfo": "Основная информация",
@@ -622,14 +606,11 @@
"proxyType": "Тип прокси",
"basicStrategy": "Базовые политики",
"editProxy": "Редактировать прокси",
"addProxyProviders": "Добавить провайдеров прокси",
"proxiesEmpty": "Список прокси пуст",
"addNodes": "Добавить узлы",
"addProxyGroup": "Добавить группу прокси",
"editProxyGroup": "Редактировать группу прокси",
"iconUrl": "URL значка",
"iconRecords": "История значков",
"noRecords": "Записей пока нет",
"custom": "Вручную",
"ruleActionDomainDesc": "Сопоставить полный домен",
"ruleActionDomainSuffixDesc": "Сопоставить суффикс домена",
@@ -668,12 +649,8 @@
"ruleActionNotDesc": "Логическое правило NOT",
"ruleActionSubRuleDesc": "Переход к подправилу; обратите внимание на скобки",
"ruleActionMatchDesc": "Сопоставляет все запросы, условия не нужны",
"subRuleEmpty": "Подправило пусто",
"proxyProvidersNotEmpty": "Провайдеры прокси не могут быть пустыми",
"contentNotEmpty": "Содержимое не может быть пустым",
"subRuleNotEmpty": "Подправило не может быть пустым",
"splitStrategyNotEmpty": "Стратегия распределения не может быть пустой",
"proxyProvidersEmpty": "Список провайдеров прокси пуст",
"timeout": "Тайм-аут",
"delayFailed": "Сбой",
"invalidSubRule": "{subRule} — недопустимый SUB_RULE",
@@ -709,13 +686,14 @@
"resourceUpdateInterval": "Интервал автообновления",
"resourceUpdateIntervalTip": "Интервал автообновления должен быть больше 0",
"hours": "часов",
"hoursCount": "{count, plural, one{{count} час} few{{count} часа} many{{count} часов} other{{count} часа}}",
"minutes": "минут",
"geoResources": "Geo",
"geoSkipped": "{name}: уже последняя версия",
"geoUpdated": "{name}: обновлено",
"secondsCount": "{count, plural, one{{count} секунда} few{{count} секунды} many{{count} секунд} other{{count} секунды}}",
"proxiesCount": "{count, plural, other{{label} прокси}}",
"rulesCount": "{count, plural, one{{label} правило} few{{label} правила} many{{label} правил} other{{label} правила}}",
"proxyGroupsCount": "{count, plural, one{{label} группа прокси} few{{label} группы прокси} many{{label} групп прокси} other{{label} группы прокси}}",
"changelogBreaking": "Важные изменения",
"changelogFeatures": "Новые функции",
"changelogFixes": "Исправления",
@@ -740,7 +718,6 @@
"nextMatch": "Следующее совпадение",
"clearSearch": "Очистить поиск",
"noSearchResults": "Ничего не найдено",
"addWidget": "Добавить виджет",
"showPassword": "Показать пароль",
"hidePassword": "Скрыть пароль",
"authorize": "Разрешить",
@@ -832,6 +809,7 @@
"filters": "Фильтры",
"label": "Метка",
"regex": "Регулярное выражение",
"matchCase": "Учитывать регистр",
"iconSets": "Наборы значков",
"invalidIconSet": "Это не набор значков",
"recommendedIcons": "Рекомендуемые",
@@ -840,6 +818,7 @@
"outbound": "Исходящие",
"strictRoute": "Строгая маршрутизация",
"congestionController": "Управление перегрузкой TCP",
"congestionControllerDesc": "Управление перегрузкой TCP действует только со стеком mips.",
"routeExcludeAddress": "Исключённые адреса маршрутов",
"keepAliveIdle": "Простой TCP keep-alive",
"disableKeepAlive": "Отключить TCP keep-alive",
@@ -856,7 +835,22 @@
"disableIcmpForwarding": "Отключить пересылку ICMP",
"disableIcmpForwardingDesc": "При отключённой пересылке ICMP TUN сам отвечает на ping, поэтому ping больше не показывает реальную задержку.",
"excludeInterface": "Исключённые интерфейсы",
"excludeInterfaceDesc": "Исключённые интерфейсы действуют только в Linux: трафик с них, например с docker0, идёт в обход TUN.",
"excludeInterfaceDesc": "Трафик с исключённых интерфейсов, например с docker0, идёт в обход TUN.",
"includeInterface": "Включённые интерфейсы",
"includeInterfaceDesc": "Через TUN идёт только трафик с включённых интерфейсов, а исключённые интерфейсы больше не действуют.",
"includeUid": "Включённые ID пользователей",
"includeUidDesc": "Через TUN идёт только трафик процессов включённых ID пользователей.",
"excludeUid": "Исключённые ID пользователей",
"excludeUidDesc": "Трафик процессов исключённых ID пользователей идёт в обход TUN.",
"excludeSrcPort": "Исключённые исходные порты",
"excludeSrcPortDesc": "Трафик с исключённых исходных портов идёт в обход TUN.",
"excludeDstPort": "Исключённые порты назначения",
"excludeDstPortDesc": "Трафик на исключённые порты назначения идёт в обход TUN.",
"udpTimeout": "Тайм-аут UDP",
"udpTimeoutDesc": "Тайм-аут UDP задаётся в секундах и действует только со стеками gvisor, system и mixed.",
"dnsHijackDesc": "Без этого параметра TUN перехватывает все DNS-запросы на порт 53 (any:53).",
"tunOverrideDesc": "Добавленные здесь параметры заменяют настройки TUN всех профилей; остальные остаются такими, как в профиле. Параметры, добавленные в самом пользовательском профиле, имеют приоритет.",
"profileTunSettingsDesc": "Действует только для этого профиля и имеет приоритет над переопределяемыми параметрами TUN в настройках сети.",
"settingsCount": "{count, plural, one{{count} настройка} few{{count} настройки} many{{count} настроек} other{{count} настройки}}",
"sectionsInEffect": "Действуют: {count}",
"itemsCount": "{count, plural, one{{count} элемент} few{{count} элемента} many{{count} элементов} other{{count} элемента}}",
@@ -905,5 +899,45 @@
"required": "Обязательно",
"ageSecretKey": "Секретный ключ age",
"ageEncryptedProfile": "Зашифрованный профиль",
"ageSecretKeyRequiredTip": "Профиль зашифрован с помощью age, для расшифровки нужен секретный ключ age"
"ageSecretKeyRequiredTip": "Профиль зашифрован с помощью age, для расшифровки нужен секретный ключ age",
"currentOutboundDesc": "Содержит кнопку запуска, удалить нельзя",
"running": "Работает",
"notRunning": "Не запущено",
"trafficPeak": "Пик {value}",
"trafficTotal": "Всего {value}",
"ipQualityShort": "Качество",
"ipTypeUnknown": "Тип неизвестен",
"tapIconToRetry": "Нажмите на значок, чтобы повторить",
"networkTakeover": "Перехват трафика",
"portValue": "Порт {port}",
"serviceAbnormalCount": "Проблем: {count}",
"serviceAllAvailable": "Все доступны",
"serviceShownOnDashboard": "Показан на панели",
"groupTimeoutCount": "Тайм-аут: {count}",
"groupCountUnit": "групп",
"groupCount": "Групп: {count}",
"proxyGroupsAfterStart": "Группы появятся здесь после запуска ядра",
"usedShort": "использовано",
"profileUpdatedAt": "Обновлено {time}",
"profileExpiresIn": "Истекает {date}, осталось дней: {days}",
"profileDaysLeft": "Осталось дней: {days}",
"target": "Цель",
"outboundExit": "Выход",
"duration": "Длительность",
"traffic": "Трафик",
"totalDownload": "Загружено",
"totalUpload": "Отдано",
"initiatorShort": "Источник",
"result": "Результат",
"elapsed": "Время",
"connectionStats": "Статистика подключений",
"connectionStatsDesc": "Подключения, запросы и DNS-запросы",
"memoryInfoDesc": "Память приложения и ядра",
"editDashboard": "Изменить панель",
"done": "Готово",
"dashboardAddableSection": "Можно добавить",
"customImportDroppedSubRules": "{count, plural, one{Пользовательские профили не поддерживают подправила, поэтому отброшено {label} правило SUB-RULE} few{Пользовательские профили не поддерживают подправила, поэтому отброшено {label} правила SUB-RULE} many{Пользовательские профили не поддерживают подправила, поэтому отброшено {label} правил SUB-RULE} other{Пользовательские профили не поддерживают подправила, поэтому отброшено {label} правила SUB-RULE}}",
"autoInclude": "Автоматическое включение",
"allReferencedProxies": "Все используемые прокси",
"allReferencedProxyProviders": "Все используемые провайдеры прокси"
}
+89 -55
View File
@@ -13,7 +13,6 @@
"trafficUsage": "流量统计",
"networkSpeed": "网络速度",
"outboundMode": "出站模式",
"networkDetection": "网络检测",
"serviceStatus": "服务状态",
"serviceRestricted": "访问受限",
"serviceAvailable": "可用",
@@ -42,13 +41,15 @@
"other": "其他",
"about": "关于",
"en": "英语",
"es": "西班牙语",
"fr": "法语",
"ja": "日语",
"ko": "韩语",
"ru": "俄语",
"zhCN": "中文简体",
"theme": "主题",
"themeDesc": "设置深色模式,调整色彩",
"extend": "扩展",
"finalConfig": "最终配置",
"runtimeConfig": "运行时配置",
"allowLan": "局域网代理",
"tun": "虚拟网卡",
"tunDesc": "虚拟网卡仅在管理员模式生效。",
@@ -64,7 +65,7 @@
"checkCertificate": "校验 TLS 证书",
"checkCertificateDesc": "关闭证书校验后,订阅和备份可能遭中间人攻击。",
"accessControl": "访问控制",
"accessControlDesc": "配置应用访问代理",
"accessControlDesc": "访问控制决定哪些应用使用代理。",
"edit": "编辑",
"confirm": "确定",
"update": "更新",
@@ -85,7 +86,7 @@
"profileUrlNullValidationDesc": "请输入配置URL",
"profileUrlInvalidValidationDesc": "请输入有效配置URL",
"autoUpdate": "自动更新",
"autoUpdateInterval": "自动更新间隔(分钟)",
"autoUpdateInterval": "自动更新间隔",
"profileAutoUpdateIntervalNullValidationDesc": "请输入自动更新间隔时间",
"profileAutoUpdateIntervalInvalidValidationDesc": "请输入有效间隔时间格式",
"themeMode": "主题模式",
@@ -180,9 +181,7 @@
"testUrl": "测速链接",
"sync": "同步",
"exclude": "从最近任务中隐藏",
"expand": "标准",
"shrink": "紧凑",
"min": "最小",
"compact": "紧凑",
"tab": "标签页",
"list": "列表",
"delay": "延迟",
@@ -202,6 +201,7 @@
"pureBlackMode": "纯黑模式",
"sidebarBlur": "侧边栏背景模糊",
"sidebarBlurDesc": "侧边栏透出窗口后方模糊的桌面",
"liquidGlass": "液态玻璃",
"keepAliveIntervalDesc": "TCP保持活动间隔",
"entries": "个条目",
"local": "本地",
@@ -216,14 +216,14 @@
"layout": "布局",
"tight": "紧凑",
"standard": "标准",
"ultra": "极致",
"loose": "宽松",
"profilesSort": "配置排序",
"start": "启动",
"runTime": "启动时间",
"runTime": "运行时长",
"stop": "暂停",
"key": "键",
"value": "值",
"vpnTip": "重启VPN后改变生效",
"vpnEnableDesc": "VPN 通过 VpnService 自动路由系统所有流量。",
"options": "选项",
"enabled": "已开启",
@@ -316,26 +316,20 @@
"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 的默认策略保留。",
"disclaimerPrivacyContent": "本软件不会收集或上传你的订阅地址、节点信息、配置内容、访问的网站、连接记录、流量内容和日志,这些数据仅保存在你的设备本地,开发者无法获取。\n\n本软件只会在你使用相应功能时访问外部网络,例如更新订阅时访问你提供的订阅地址、检查更新时访问 GitHub。\n\n本软件在所有平台上均未集成任何数据统计或崩溃上报服务。",
"disclaimerLicenseTitle": "开源协议",
"disclaimerLicenseContent": "本软件以 GPL-3.0 协议开源。你可以在遵守该协议的前提下自由使用、修改和分发本软件,但须同样以 GPL-3.0 协议开源并保留原作者的版权声明。\n\n本软件包含的第三方组件(包括 Clash.Meta 内核)分别遵循其各自的开源协议。基于本软件修改或分发的版本所产生的任何问题,与原作者无关。",
"disclaimerChangesTitle": "声明变更",
"disclaimerChangesContent": "开发者有权随版本更新修改本声明,修改后的内容随新版本发布即生效。你在更新后继续使用本软件,即视为接受修改后的声明。",
"disclaimerAcceptTitle": "接受声明",
"disclaimerAcceptContent": "一旦你安装、复制或使用本软件,即视为你已阅读并同意本声明的全部内容。如你不同意本声明的任何条款,请立即停止使用并卸载本软件。",
"disclaimerAndroidOnly": "仅 Android",
"disclaimerGooglePrivacy": "Google 隐私政策",
"disclaimerFirebasePrivacy": "Firebase 隐私和安全说明",
"disclaimerReadToEnd": "请阅读至底部",
"disclaimerRestateTitle": "复述声明",
"disclaimerRestateTip": "为确认你已知悉,请在下方输入框中完整复述以下声明:",
"disclaimerRestateHint": "在此输入上述声明",
"disclaimerRestateMismatch": "与声明内容不一致",
"disclaimerAcknowledgeTitle": "确认声明",
"disclaimerAcknowledgeTip": "为确认你已知悉,请阅读以下声明并勾选下方选项:",
"disclaimerAcknowledgeLabel": "我已阅读并理解此声明",
"agree": "同意",
"hotkeyManagement": "快捷键管理",
"hotkeyDesc": "全局快捷键在窗口隐藏时依然生效,点击操作即可录制组合键。",
@@ -405,12 +399,9 @@
"lineWrap": "自动换行",
"none": "无",
"advancedConfig": "进阶配置",
"advancedConfigDesc": "网络、DNS、附加规则与脚本",
"selectedCountTitle": "已选择 {count} 项",
"advancedConfigDesc": "进阶配置包含网络、DNS、附加规则与脚本。",
"addRule": "添加规则",
"ruleName": "规则名称",
"content": "内容",
"subRule": "子规则",
"ruleTarget": "规则目标",
"matchTarget": "MATCH-TARGET",
"followProfile": "跟随配置",
@@ -424,17 +415,19 @@
"slide": "滑动",
"fade": "淡入",
"pureBlack": "纯黑",
"colorSchemes": "配色方案",
"palette": "调色板",
"tonalSpotScheme": "调性点缀",
"fidelityScheme": "高保真",
"customColor": "自定义颜色",
"colorStyle": "配色风格",
"grid": "网格",
"spectrum": "光谱",
"sliders": "滑块",
"hue": "色相",
"saturation": "饱和度",
"brightness": "亮度",
"hex": "十六进制",
"monochromeScheme": "单色",
"neutralScheme": "中性",
"vibrantScheme": "活力",
"expressiveScheme": "表现力",
"contentScheme": "内容主题",
"rainbowScheme": "彩虹",
"fruitSaladScheme": "果缤纷",
"vividStyle": "鲜明",
"tintedStyle": "氛围",
"softStyle": "柔和",
"developerMode": "开发者模式",
"developerModeEnableTip": "开发者模式已启用。",
"messageTest": "消息测试",
@@ -514,10 +507,6 @@
"forceRestartCoreTip": "您确定要强制重启核心吗?",
"dnsHijacking": "DNS劫持",
"coreStatus": "核心状态",
"dataCollectionTip": "数据收集说明",
"dataCollectionContent": "本应用使用 Firebase Crashlytics 收集崩溃信息以改进应用稳定性。\n收集的数据包括设备信息和崩溃详情,不包含个人敏感数据。\n您可以在设置中关闭此功能。",
"crashlytics": "崩溃分析",
"crashlyticsTip": "崩溃分析开启后,应用崩溃时会上传不含敏感信息的日志。",
"appendSystemDns": "追加系统DNS",
"editRule": "编辑规则",
"extendMode": "扩展模式",
@@ -562,7 +551,7 @@
"customProfile": "自定义",
"customProfileDesc": "基于应用代理和其他配置组建策略组与规则",
"customProfileResources": "自定义配置资源",
"customProfileResourcesDesc": "代理、规则集、筛选与图标集",
"customProfileResourcesDesc": "自定义配置资源包含代理、规则集、筛选与图标集。",
"unknownNetworkError": "未知网络错误",
"backupFromNewerVersion": "该备份来自更高版本的应用,请先更新应用再恢复",
"networkTimeoutError": "请求超时,请检查网络或代理后重试",
@@ -579,7 +568,6 @@
"invalidBackupFile": "无效备份文件",
"pruneCache": "修剪缓存",
"backupAndRestore": "备份与恢复",
"backupAndRestoreDesc": "通过WebDAV或者文件同步数据",
"restore": "恢复",
"restoreSuccess": "恢复成功",
"restoreOnlyConfig": "仅恢复配置文件",
@@ -587,9 +575,7 @@
"addProfile": "添加配置",
"delayTest": "延迟测试",
"proxyGroupEmpty": "策略组为空",
"proxyGroupNameDuplicate": "策略组名称重复",
"confirmExitWindow": "确定要退出当前窗口吗?",
"selectProxyProviders": "选择代理集",
"optional": "可选",
"maxFailedTimes": "最大失败次数",
"testInterval": "测试间隔",
@@ -597,7 +583,6 @@
"expectedStatus": "预期状态",
"strategy": "策略",
"tolerance": "容差",
"selectProxies": "选择代理",
"inputProxyGroupName": "输入策略组名称",
"helperCorruptTip": "Helper 服务不可用,无法启用 TUN 模式,请重新安装 FlClash。",
"tunAuthorizationFailed": "虚拟网卡授权失败,已关闭。",
@@ -614,7 +599,6 @@
"selectRuleSet": "请选择规则集",
"splitStrategy": "分流策略",
"selectSplitStrategy": "请选择分流策略",
"selectSubRule": "请选择子规则",
"noResolveHostname": "不解析主机名",
"matchSourceIp": "匹配来源IP",
"basicInfo": "基础信息",
@@ -622,14 +606,11 @@
"proxyType": "代理类型",
"basicStrategy": "基础策略",
"editProxy": "编辑代理",
"addProxyProviders": "添加代理集",
"proxiesEmpty": "代理为空",
"addNodes": "添加节点",
"addProxyGroup": "添加策略组",
"editProxyGroup": "编辑策略组",
"iconUrl": "图标链接",
"iconRecords": "图标记录",
"noRecords": "暂无记录",
"custom": "自定义",
"ruleActionDomainDesc": "匹配完整域名",
"ruleActionDomainSuffixDesc": "匹配域名后缀",
@@ -668,12 +649,8 @@
"ruleActionNotDesc": "逻辑规则 NOT",
"ruleActionSubRuleDesc": "匹配至子规则,需要注意括号的使用",
"ruleActionMatchDesc": "匹配所有请求,无需条件",
"subRuleEmpty": "子规则为空",
"proxyProvidersNotEmpty": "代理集不能为空",
"contentNotEmpty": "内容不能为空",
"subRuleNotEmpty": "子规则不能为空",
"splitStrategyNotEmpty": "分流策略不能为空",
"proxyProvidersEmpty": "代理集为空",
"timeout": "超时",
"delayFailed": "失败",
"invalidSubRule": "{subRule} 是一个无效的SUB_RULE",
@@ -709,13 +686,14 @@
"resourceUpdateInterval": "自动更新间隔",
"resourceUpdateIntervalTip": "自动更新间隔必须大于0",
"hours": "小时",
"hoursCount": "{count} 小时",
"minutes": "分钟",
"geoResources": "Geo",
"geoSkipped": "{name} 已是最新版本",
"geoUpdated": "{name} 已更新",
"secondsCount": "{count} 秒",
"proxiesCount": "{count, plural, other{{label} 个代理}}",
"rulesCount": "{count, plural, other{{label} 条规则}}",
"proxyGroupsCount": "{count, plural, other{{label} 个策略组}}",
"changelogBreaking": "重大变更",
"changelogFeatures": "新功能",
"changelogFixes": "问题修复",
@@ -740,7 +718,6 @@
"nextMatch": "下一个匹配",
"clearSearch": "清除搜索",
"noSearchResults": "没有匹配的结果",
"addWidget": "添加组件",
"showPassword": "显示密码",
"hidePassword": "隐藏密码",
"authorize": "授权",
@@ -832,6 +809,7 @@
"filters": "筛选",
"label": "标签",
"regex": "正则",
"matchCase": "区分大小写",
"iconSets": "图标集",
"invalidIconSet": "不是有效的图标集",
"recommendedIcons": "推荐",
@@ -840,6 +818,7 @@
"outbound": "出站",
"strictRoute": "严格路由",
"congestionController": "TCP 拥塞控制",
"congestionControllerDesc": "TCP 拥塞控制仅在 mips 栈生效。",
"routeExcludeAddress": "排除路由地址",
"keepAliveIdle": "TCP 保持活动空闲时间",
"disableKeepAlive": "禁用 TCP 保持活动",
@@ -856,7 +835,22 @@
"disableIcmpForwarding": "禁用ICMP转发",
"disableIcmpForwardingDesc": "禁用 ICMP 转发后由虚拟网卡直接应答 ping,ping 将不再显示真实延迟。",
"excludeInterface": "排除网卡",
"excludeInterfaceDesc": "排除网卡仅在 Linux 生效,从这些网卡(如 docker0)进入的流量不经过虚拟网卡。",
"excludeInterfaceDesc": "从这些网卡(如 docker0)进入的流量不经过虚拟网卡。",
"includeInterface": "包含网卡",
"includeInterfaceDesc": "只有从包含网卡进入的流量经过虚拟网卡,设置后排除网卡不再生效。",
"includeUid": "包含用户 ID",
"includeUidDesc": "只有这些用户 ID 的进程产生的流量经过虚拟网卡。",
"excludeUid": "排除用户 ID",
"excludeUidDesc": "这些用户 ID 的进程产生的流量不经过虚拟网卡。",
"excludeSrcPort": "排除源端口",
"excludeSrcPortDesc": "源端口为这些端口的流量不经过虚拟网卡。",
"excludeDstPort": "排除目标端口",
"excludeDstPortDesc": "发往这些目标端口的流量不经过虚拟网卡。",
"udpTimeout": "UDP 超时",
"udpTimeoutDesc": "UDP 超时以秒为单位,仅在 gvisor、system 和 mixed 栈生效。",
"dnsHijackDesc": "未添加时劫持所有发往 53 端口的 DNS 请求(any:53)。",
"tunOverrideDesc": "添加的项会覆写所有配置的虚拟网卡设置,未添加的项沿用配置自身的设置。自定义配置自己添加的项优先。",
"profileTunSettingsDesc": "仅作用于当前配置,并优先于网络设置中的虚拟网卡覆写项。",
"settingsCount": "{count} 项设置",
"sectionsInEffect": "{count} 项生效",
"itemsCount": "{count} 项",
@@ -905,5 +899,45 @@
"required": "必填",
"ageSecretKey": "age 私钥",
"ageEncryptedProfile": "配置已加密",
"ageSecretKeyRequiredTip": "该配置已用 age 加密,需要 age 私钥才能解密"
"ageSecretKeyRequiredTip": "该配置已用 age 加密,需要 age 私钥才能解密",
"currentOutboundDesc": "含启动键,不可移除",
"running": "运行中",
"notRunning": "未运行",
"trafficPeak": "峰值 {value}",
"trafficTotal": "累计 {value}",
"ipQualityShort": "质量",
"ipTypeUnknown": "类型未知",
"tapIconToRetry": "点击图标重试",
"networkTakeover": "网络接管",
"portValue": "端口 {port}",
"serviceAbnormalCount": "{count} 项异常",
"serviceAllAvailable": "全部可用",
"serviceShownOnDashboard": "显示在仪表盘",
"groupTimeoutCount": "{count} 超时",
"groupCountUnit": "个",
"groupCount": "{count} 个",
"proxyGroupsAfterStart": "启动后在这里显示策略组",
"usedShort": "已用",
"profileUpdatedAt": "更新于 {time}",
"profileExpiresIn": "{date} 到期,剩余 {days} 天",
"profileDaysLeft": "剩余 {days} 天",
"target": "目标",
"outboundExit": "出口",
"duration": "时长",
"traffic": "流量",
"totalDownload": "下载总量",
"totalUpload": "上传总量",
"initiatorShort": "发起",
"result": "结果",
"elapsed": "耗时",
"connectionStats": "连接统计",
"connectionStatsDesc": "实时连接、最近请求、DNS查询",
"memoryInfoDesc": "应用与内核占用的内存",
"editDashboard": "编辑仪表盘",
"done": "完成",
"dashboardAddableSection": "可添加",
"customImportDroppedSubRules": "{count, plural, other{自定义配置不支持子规则,已丢弃 {label} 条 SUB-RULE 规则}}",
"autoInclude": "自动包含",
"allReferencedProxies": "全部被引用的代理",
"allReferencedProxyProviders": "全部被引用的代理集"
}
+2 -1
View File
@@ -10,4 +10,5 @@ targets:
freezed:
options:
build_extensions:
'^lib/models/{{}}.dart': 'lib/models/generated/{{}}.freezed.dart'
'^lib/models/{{}}.dart': 'lib/models/generated/{{}}.freezed.dart'
'^lib/ui/{{}}.dart': 'lib/ui/generated/{{}}.freezed.dart'
+78
View File
@@ -1,6 +1,84 @@
{
"schemaVersion": 2,
"versions": [
{
"version": "0.9.0",
"tag": "v0.9.0",
"date": "2026-10-14",
"prerelease": false,
"groups": [
{
"type": "breaking",
"entries": [
{
"id": "491fae1",
"scope": "views",
"text": "DNS and NTP override toggles are removed; picked keys apply to every profile"
},
{
"id": "1677024",
"scope": "database",
"text": "Custom overwrites are removed; rebuild them as custom profiles"
}
]
},
{
"type": "feat",
"entries": [
{
"id": "491fae1",
"scope": "views",
"text": "Per-key DNS, NTP, sniffer and TUN settings"
},
{
"id": "3abda78",
"scope": "profiles",
"text": "Custom profiles"
},
{
"id": "8c43053",
"scope": "ui",
"text": "Stacked sheets, context menus and a sort mode"
},
{
"id": "532ebf0",
"scope": "providers",
"text": "DNS, Hosts and ports apply without a restart"
},
{
"id": "d3fa225",
"scope": "android",
"text": "Launcher shortcuts and one-step on-demand access"
},
{
"id": "2fa2f7f",
"scope": "core",
"text": "Share link import and mrs rule sets"
}
]
},
{
"type": "fix",
"entries": [
{
"id": "1677024",
"scope": "database",
"text": "Unreadable settings no longer block startup or restores"
},
{
"id": "94a25f4",
"scope": "desktop",
"text": "Windows and Linux desktop fixes"
},
{
"id": "d780cb8",
"scope": "core,helper",
"text": "The elevated Core only answers its owner"
}
]
}
]
},
{
"version": "0.8.99",
"tag": "v0.8.99",
+15 -20
View File
@@ -63,6 +63,7 @@ func handleSetupConfig(params *SetupParams) error {
}
func handleGetConfig(path string) (*config.RawConfig, error) {
defer scheduleLoadMemoryRelease()
buf, err := os.ReadFile(path)
if err != nil {
return nil, err
@@ -100,11 +101,23 @@ func loadConfig(path string) (*config.Config, error) {
if err != nil {
return nil, err
}
return withPlatformLimits(cfg), nil
}
func withPlatformLimits(cfg *config.Config) *config.Config {
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
return cfg
}
func loadSetupConfig(params *SetupParams) (*config.Config, error) {
path := filepath.Join(constant.Path.HomeDir(), "config.yaml")
if params.Base != nil {
return loadConfigWithBase(params.Base, path)
}
return loadConfig(path)
}
func applyConfig(params *SetupParams) error {
@@ -114,7 +127,7 @@ func applyConfig(params *SetupParams) error {
setTestURL(params.TestURL)
skipCertVerify.Store(params.SkipCertVerify)
cfg, err := loadConfig(filepath.Join(constant.Path.HomeDir(), "config.yaml"))
cfg, err := loadSetupConfig(params)
if err != nil {
// The fallback is what keeps the listeners serving while the host
// reports the error, but it applies a config with no proxies in it.
@@ -214,27 +227,9 @@ func patchTun(target *LC.Tun, params *tunSchema) {
if params.Device != nil {
target.Device = *params.Device
}
if params.RouteAddress != nil {
target.RouteAddress = *params.RouteAddress
}
if params.DNSHijack != nil {
target.DNSHijack = *params.DNSHijack
}
if params.Stack != nil {
target.Stack = *params.Stack
}
if params.MTU != nil {
target.MTU = *params.MTU
}
if params.CongestionController != nil {
target.CongestionController = *params.CongestionController
}
if params.StrictRoute != nil {
target.StrictRoute = *params.StrictRoute
}
if params.RouteExcludeAddress != nil {
target.RouteExcludeAddress = *params.RouteExcludeAddress
}
}
func routeConfig(cfg *config.Config) *route.Config {
+6 -6
View File
@@ -246,9 +246,9 @@ func TestUpdateConfigPatchesOnlyTheTunFieldsItWasGiven(t *testing.T) {
func TestUpdateConfigDecodesTheTunSettingsTheHostSends(t *testing.T) {
withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}})
currentConfig.General.Tun.MTU = 1400
var params UpdateParams
payload := `{"tun":{"enable":true,"mtu":1400,"congestion-controller":"bbr","strict-route":true,
"route-exclude-address":["10.0.0.0/8"]}}`
payload := `{"tun":{"enable":true,"device":"FlClash","stack":"gvisor","auto-route":true,"mtu":9000}}`
if err := json.Unmarshal([]byte(payload), &params); err != nil {
t.Fatalf("decode: %v", err)
}
@@ -257,11 +257,11 @@ func TestUpdateConfigDecodesTheTunSettingsTheHostSends(t *testing.T) {
t.Fatalf("updateConfig error: %v", err)
}
tun := currentConfig.General.Tun
if tun.MTU != 1400 || tun.CongestionController != "bbr" || !tun.StrictRoute {
t.Errorf("MTU = %d, CongestionController = %q, StrictRoute = %v", tun.MTU, tun.CongestionController, tun.StrictRoute)
if !tun.Enable || !tun.AutoRoute || tun.Device != "FlClash" || tun.Stack != constant.TunGvisor {
t.Errorf("Enable = %v, AutoRoute = %v, Device = %q, Stack = %v", tun.Enable, tun.AutoRoute, tun.Device, tun.Stack)
}
if len(tun.RouteExcludeAddress) != 1 || tun.RouteExcludeAddress[0] != netip.MustParsePrefix("10.0.0.0/8") {
t.Errorf("RouteExcludeAddress = %v", tun.RouteExcludeAddress)
if tun.MTU != 1400 {
t.Errorf("MTU = %d, want the profile's value kept", tun.MTU)
}
}
+4 -1
View File
@@ -20,7 +20,10 @@ func init() {
}
statistic.DefaultRequestNotify = func(c statistic.Tracker) {
notifyProbeRoute(c)
sendMessage(RequestMessage, c)
if hasEventListener() {
sendMessage(RequestMessage, c)
watchRequestTraffic(c)
}
}
dns.DefaultQueryNotify = func(record dns.QueryRecord) {
if hasEventListener() {
+11 -1
View File
@@ -374,9 +374,19 @@ func StopListener() {
}
}
var forceGCPending atomic.Bool
// Android calls this from onLowMemory and onTrimMemory on the main thread, which
// the Flutter UI shares, and a full collection of a large profile spans frames.
func ForceGC() {
defer recoverExport("forceGC")
handleForceGC()
if !forceGCPending.CompareAndSwap(false, true) {
return
}
safeGo("forceGC", func() {
defer forceGCPending.Store(false)
handleForceGC()
})
}
func UpdateDns(value string) {
+1
View File
@@ -31,6 +31,7 @@ func handleInitClash(params *InitParams) bool {
sdkVersion.Store(int32(params.Version))
constant.SetHomeDir(params.HomeDir)
initOwnership(params.HomeDir)
clearSpilledResponses()
isInit.Store(true)
return true
}
+13 -10
View File
@@ -40,7 +40,7 @@ func classOfMessage(message Message) messageClass {
return stateMessageClass
case LogMessage, RequestMessage:
return bulkMessageClass
case DnsMessage:
case DnsMessage, RequestTrafficMessage:
return yieldingBulkMessageClass
default:
return priorityMessageClass
@@ -49,35 +49,37 @@ func classOfMessage(message Message) messageClass {
// 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(messageType MessageType, data any) {
func sendMessage(messageType MessageType, data any) (queued bool) {
if !hasEventListener() {
return
return false
}
message := Message{Type: messageType, Data: data}
switch classOfMessage(message) {
case stateMessageClass:
enqueueState(stateMessageQueue, message)
return enqueueState(stateMessageQueue, message)
case bulkMessageClass:
enqueueLatest(bulkMessageQueue, message)
return enqueueLatest(bulkMessageQueue, message)
case yieldingBulkMessageClass:
enqueueState(bulkMessageQueue, message)
return enqueueState(bulkMessageQueue, message)
default:
enqueueLatest(priorityMessageQueue, message)
return enqueueLatest(priorityMessageQueue, message)
}
}
func enqueueState(queue chan Message, message Message) {
func enqueueState(queue chan Message, message Message) bool {
select {
case queue <- message:
return true
default:
return false
}
}
func enqueueLatest(queue chan Message, message Message) {
func enqueueLatest(queue chan Message, message Message) bool {
for attempt := 0; attempt < messageEvictAttempts; attempt++ {
select {
case queue <- message:
return
return true
default:
}
@@ -86,6 +88,7 @@ func enqueueLatest(queue chan Message, message Message) {
default:
}
}
return false
}
func runMessageBatcher(
+9 -8
View File
@@ -55,14 +55,15 @@ 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,
RouteChangedMessage: stateMessageClass,
DelayMessage: priorityMessageClass,
LogMessage: bulkMessageClass,
RequestMessage: bulkMessageClass,
DnsMessage: yieldingBulkMessageClass,
DialerLoopMessage: priorityMessageClass,
LoadedMessage: stateMessageClass,
GeoUpdateMessage: stateMessageClass,
RouteChangedMessage: stateMessageClass,
DelayMessage: priorityMessageClass,
LogMessage: bulkMessageClass,
RequestMessage: bulkMessageClass,
DnsMessage: yieldingBulkMessageClass,
RequestTrafficMessage: yieldingBulkMessageClass,
DialerLoopMessage: priorityMessageClass,
} {
if got := classOfMessage(Message{Type: messageType}); got != want {
t.Errorf("classOfMessage(%s) = %d, want %d", messageType, got, want)
+5 -1
View File
@@ -29,6 +29,7 @@ type MethodResponse struct {
ID string `json:"id,omitempty"`
Result any `json:"result"`
Error *MethodError `json:"error,omitempty"`
File string `json:"file,omitempty"`
}
var methodCallSeq atomic.Uint64
@@ -75,7 +76,7 @@ func handleMethodCall(call *MethodCall) (data []byte) {
if err != nil {
return encodeResponse(MethodResponse{ID: call.ID, Error: asMethodError(err)})
}
return encodeResponse(MethodResponse{ID: call.ID, Result: result})
return spillResponse(call.ID, encodeResponse(MethodResponse{ID: call.ID, Result: result}))
}
func encodeResponse(response MethodResponse) []byte {
@@ -189,6 +190,9 @@ var methodHandlers = map[CoreMethod]methodHandler{
validateConfigMethod: withMessage(handleValidateConfig),
decryptConfigMethod: withFallible(handleDecryptConfig),
getConfigMethod: withFallible(handleGetConfig),
getProfileBaseMethod: withFallible(handleGetProfileBase),
getProfileOutboundsMethod: withFallible(handleGetProfileOutbounds),
buildProfileMethod: withFallible(handleBuildProfile),
setupConfigMethod: withMessage(handleSetupConfig),
updateConfigMethod: withMessage(updateConfig),
validateProxiesMethod: withArguments(handleValidateProxies),
+2
View File
@@ -5,3 +5,5 @@ package core
func initOwnership(homeDir string) {}
func scheduleReclaimOwnership() {}
func reclaimDirOwnership(path string) {}
+16
View File
@@ -153,6 +153,22 @@ func reclaimOwnership(homeDir string) {
log.Infoln("[APP] reclaimed %d of %d elevated entries under %s", reclaimed, len(targets), homeDir)
}
func reclaimDirOwnership(path string) {
homeDir, _ := reclaimHomeDir.Load().(string)
if !isElevated() || !canReclaim(homeDir, realUid) {
return
}
info, err := os.Lstat(path)
if err != nil || !info.IsDir() || !shouldReclaimEntry(info, realUid) {
return
}
stat := info.Sys().(*syscall.Stat_t)
target := reclaimTarget{path: path, dev: uint64(stat.Dev), ino: uint64(stat.Ino)}
if _, err := reclaimEntry(target, realUid, realGid); err != nil {
log.Warnln("[APP] reclaim %s: %v", path, err)
}
}
func initOwnership(homeDir string) {
reclaimHomeDir.Store(homeDir)
reclaimOwnership(homeDir)
+356
View File
@@ -0,0 +1,356 @@
package core
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/metacubex/mihomo/component/age"
"github.com/metacubex/mihomo/config"
"go.yaml.in/yaml/v3"
)
// What makes a profile large stays in the Core; the overlay the host sends
// back replaces every other section.
var profileBaseSections = []string{"proxies", "proxy-groups", "sub-rules"}
func handleGetProfileBase(path string) (*ProfileBase, error) {
defer scheduleLoadMemoryRelease()
buf, err := os.ReadFile(path)
if err != nil {
return nil, err
}
digest := sha256.Sum256(buf)
raw, err := unmarshalRawConfig(buf)
if err != nil {
return nil, err
}
base := &ProfileBase{
Digest: hex.EncodeToString(digest[:]),
MatchRules: []string{},
Proxies: len(raw.Proxy),
ProxyGroups: len(raw.ProxyGroup),
Rules: len(raw.Rule),
}
for _, rule := range raw.Rule {
if isMatchRule(rule) {
base.MatchRules = append(base.MatchRules, rule)
}
}
raw.Proxy, raw.ProxyGroup, raw.Rule, raw.SubRules = nil, nil, nil, nil
base.Config = raw
return base, nil
}
func handleGetProfileOutbounds(path string) (*ProfileOutbounds, error) {
defer scheduleLoadMemoryRelease()
buf, err := os.ReadFile(path)
if err != nil {
return nil, err
}
raw, err := unmarshalRawConfig(buf)
if err != nil {
return nil, err
}
return &ProfileOutbounds{Proxies: outboundsOf(raw.Proxy), ProxyGroups: outboundsOf(raw.ProxyGroup)}, nil
}
func outboundsOf(mappings []map[string]any) []ProfileOutbound {
outbounds := make([]ProfileOutbound, 0, len(mappings))
for _, mapping := range mappings {
name, _ := mapping["name"].(string)
kind, _ := mapping["type"].(string)
outbounds = append(outbounds, ProfileOutbound{Name: name, Type: kind})
}
return outbounds
}
// The merged config goes to the spill directory, so the host reads it as text
// and never decodes it.
func handleBuildProfile(base *SetupBase) (string, error) {
defer scheduleLoadMemoryRelease()
document, err := mergeProfileBase(base)
if err != nil {
return "", err
}
dir, err := makeSpilledResponseDir()
if err != nil {
return "", err
}
path, err := writeDocumentFile(dir, "*.yaml", document)
if err != nil {
return "", err
}
scheduleReclaimOwnership()
return path, nil
}
// Mirrors Rule.parse in Dart, which reads the type from the first field.
func isMatchRule(rule string) bool {
kind, _, _ := strings.Cut(rule, ",")
return strings.EqualFold(strings.TrimSpace(kind), "MATCH")
}
func loadConfigWithBase(base *SetupBase, configPath string) (*config.Config, error) {
document, err := mergeProfileBase(base)
if err != nil {
return nil, err
}
raw := config.DefaultRawConfig()
if err := document.Decode(raw); err != nil {
return nil, err
}
if err := writeConfigDocument(configPath, document); err != nil {
return nil, err
}
cfg, err := config.ParseRawConfig(raw)
if err != nil {
return nil, err
}
return withPlatformLimits(cfg), nil
}
func mergeProfileBase(base *SetupBase) (*yaml.Node, error) {
buf, err := os.ReadFile(base.Path)
if err != nil {
return nil, err
}
if buf, err = age.DecryptBytes(buf); err != nil {
return nil, fmt.Errorf("decrypt config error: %w", err)
}
retag := bytes.Contains(buf, []byte("short-id"))
var profileDocument, overlayDocument yaml.Node
if err := yaml.Unmarshal(buf, &profileDocument); err != nil {
return nil, err
}
if err := yaml.Unmarshal([]byte(base.Overlay), &overlayDocument); err != nil {
return nil, err
}
profile, err := documentMapping(&profileDocument)
if err != nil {
return nil, err
}
root, err := documentMapping(&overlayDocument)
if err != nil {
return nil, err
}
for _, key := range profileBaseSections {
if value := mappingValue(profile, key); value != nil {
setMappingValue(root, key, value)
}
}
rules, err := appendRules(mappingValue(root, "rules"), mappingValue(profile, "rules"))
if err != nil {
return nil, err
}
if rules != nil {
setMappingValue(root, "rules", rules)
}
if retag {
retagShortIDs(root)
}
return &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}}, nil
}
func documentMapping(document *yaml.Node) (*yaml.Node, error) {
node := document
if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
node = resolveAlias(node.Content[0])
}
switch {
case node.Kind == yaml.MappingNode:
return node, nil
case node.Kind == 0, node.Kind == yaml.DocumentNode, isNull(node):
return &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}, nil
}
return nil, fmt.Errorf("line %d: a config must be a mapping", node.Line)
}
// A key the mapping lacks is looked up in its merge keys, as the decoder does.
func mappingValue(mapping *yaml.Node, key string) *yaml.Node {
var sources []*yaml.Node
for i := 0; i+1 < len(mapping.Content); i += 2 {
name, value := mapping.Content[i], mapping.Content[i+1]
if name.Kind != yaml.ScalarNode {
continue
}
if name.ShortTag() == "!!merge" {
sources = append(sources, value)
} else if name.Value == key {
return value
}
}
for _, source := range sources {
source = resolveAlias(source)
candidates := []*yaml.Node{source}
if source.Kind == yaml.SequenceNode {
candidates = source.Content
}
for _, candidate := range candidates {
if candidate = resolveAlias(candidate); candidate.Kind == yaml.MappingNode {
if value := mappingValue(candidate, key); value != nil {
return value
}
}
}
}
return nil
}
func setMappingValue(mapping *yaml.Node, key string, value *yaml.Node) {
for i := 0; i+1 < len(mapping.Content); i += 2 {
if name := mapping.Content[i]; name.Kind == yaml.ScalarNode && name.Value == key {
mapping.Content[i+1] = value
return
}
}
mapping.Content = append(
mapping.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
value,
)
}
// The overlay's rules are the ones the host adds in front of the profile's.
func appendRules(added, profile *yaml.Node) (*yaml.Node, error) {
if profile == nil {
return nil, nil
}
added, profile = resolveAlias(added), resolveAlias(profile)
if added == nil || isNull(added) || (added.Kind == yaml.SequenceNode && len(added.Content) == 0) {
return profile, nil
}
if isNull(profile) {
return added, nil
}
if added.Kind != yaml.SequenceNode || profile.Kind != yaml.SequenceNode {
return nil, fmt.Errorf("line %d: rules must be a list", profile.Line)
}
content := make([]*yaml.Node, 0, len(added.Content)+len(profile.Content))
content = append(append(content, added.Content...), profile.Content...)
return &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq", Content: content}, nil
}
func resolveAlias(node *yaml.Node) *yaml.Node {
for node != nil && node.Kind == yaml.AliasNode && node.Alias != nil {
node = node.Alias
}
return node
}
func isNull(node *yaml.Node) bool {
return node.Kind == yaml.ScalarNode && node.ShortTag() == "!!null"
}
// config.yaml is read again without this process: by the Android service
// starting on its own and by mihomo's own reload through the controller.
func writeConfigDocument(path string, document *yaml.Node) error {
temporary, err := writeDocumentFile(filepath.Dir(path), ".config-*.yaml", document)
if err != nil {
return err
}
if err := os.Rename(temporary, path); err != nil {
_ = os.Remove(temporary)
return err
}
scheduleReclaimOwnership()
return nil
}
func writeDocumentFile(dir, pattern string, document *yaml.Node) (string, error) {
inlineAliases(document)
file, err := os.CreateTemp(dir, pattern)
if err != nil {
return "", err
}
writer := bufio.NewWriterSize(file, 1<<20)
err = errors.Join(
encodeConfigMapping(writer, document.Content[0]),
writer.Flush(),
file.Close(),
os.Chmod(file.Name(), 0o644),
)
if err != nil {
_ = os.Remove(file.Name())
return "", err
}
return file.Name(), nil
}
// One Encode call keeps every event it emitted until it returns, several times
// the size of a large profile, so a long top-level list goes out in slices.
const encodeSliceLength = 4096
// The mapping is drained as it is written, so the parse that follows does not
// share the heap with the whole tree.
func encodeConfigMapping(w io.Writer, mapping *yaml.Node) error {
for i := 0; i+1 < len(mapping.Content); i += 2 {
key, value := mapping.Content[i], mapping.Content[i+1]
mapping.Content[i+1] = nil
if value.Kind != yaml.SequenceNode || len(value.Content) <= encodeSliceLength {
pair := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{key, value}}
if err := encodeYamlPiece(w, pair, nil); err != nil {
return err
}
continue
}
if err := encodeYamlPiece(w, key, []byte(":\n")); err != nil {
return err
}
for start := 0; start < len(value.Content); start += encodeSliceLength {
items := value.Content[start:min(start+encodeSliceLength, len(value.Content))]
slice := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq", Content: items}
if err := encodeYamlPiece(w, slice, nil); err != nil {
return err
}
clear(items)
}
}
return nil
}
// Each piece is a document of its own; an end marker left between two of them
// would end config.yaml there.
func encodeYamlPiece(w io.Writer, node *yaml.Node, suffix []byte) error {
var buffer bytes.Buffer
encoder := yaml.NewEncoder(&buffer)
encoder.SetIndent(2)
if err := errors.Join(encoder.Encode(node), encoder.Close()); err != nil {
return err
}
piece := bytes.TrimSuffix(buffer.Bytes(), []byte("...\n"))
if suffix != nil {
piece = append(bytes.TrimSuffix(piece, []byte("\n")), suffix...)
}
_, err := w.Write(piece)
return err
}
// An anchor may sit in a section the overlay replaced, so every alias is
// written out in full, as the host's own encoder always did.
func inlineAliases(root *yaml.Node) {
visited := map[*yaml.Node]struct{}{}
var walk func(node *yaml.Node)
walk = func(node *yaml.Node) {
if _, seen := visited[node]; seen {
return
}
if node.Anchor != "" {
visited[node] = struct{}{}
node.Anchor = ""
}
for i, child := range node.Content {
child = resolveAlias(child)
node.Content[i] = child
walk(child)
}
}
walk(root)
}
+320
View File
@@ -0,0 +1,320 @@
package core
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/metacubex/mihomo/config"
"github.com/metacubex/mihomo/constant"
"go.yaml.in/yaml/v3"
)
const baseProfile = `x-defaults: &socks {type: socks5, server: 127.0.0.1, port: 1080}
mixed-port: 7000
dns:
enable: true
nameserver: [223.5.5.5]
proxies:
- {<<: *socks, name: a}
- name: b
type: vless
server: 127.0.0.1
port: 443
uuid: 2a70e3a9-b4ce-4f20-908d-e99950a0a611
network: tcp
tls: true
reality-opts: {public-key: SbVKOEMjK0sIlbwg4akyBg5mL5KZwwB-ed4eEE7YnRc, short-id: 0123}
proxy-groups:
- {name: g, type: select, proxies: [a, b]}
sub-rules:
sub: ["MATCH,g"]
rules:
- DOMAIN-SUFFIX,example.com,g
- match,DIRECT
- MATCH,g
`
func writeBaseProfile(t *testing.T, profile string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "profile.yaml")
if err := os.WriteFile(path, []byte(profile), 0o600); err != nil {
t.Fatalf("write profile: %v", err)
}
return path
}
// The overlay is the host's patch of what getProfileBase returned.
func overlayOf(t *testing.T, base *ProfileBase, rules []string, patch map[string]any) string {
t.Helper()
data, err := json.Marshal(base.Config)
if err != nil {
t.Fatalf("marshal base: %v", err)
}
var overlay map[string]any
if err := json.Unmarshal(data, &overlay); err != nil {
t.Fatalf("unmarshal base: %v", err)
}
delete(overlay, "rule")
overlay["rules"] = rules
for key, value := range patch {
overlay[key] = value
}
data, err = json.Marshal(overlay)
if err != nil {
t.Fatalf("marshal overlay: %v", err)
}
return string(data)
}
func ruleStrings(cfg *config.Config) []string {
rules := make([]string, 0, len(cfg.Rules))
for _, rule := range cfg.Rules {
rules = append(rules, rule.RuleType().String()+","+rule.Payload()+","+rule.Adapter())
}
return rules
}
func TestHandleGetProfileBaseLeavesTheBulkSectionsInTheCore(t *testing.T) {
path := writeBaseProfile(t, baseProfile)
base, err := handleGetProfileBase(path)
if err != nil {
t.Fatalf("handleGetProfileBase = %v", err)
}
raw := base.Config
if raw.Proxy != nil || raw.ProxyGroup != nil || raw.Rule != nil || raw.SubRules != nil {
t.Errorf("config kept a bulk section: %+v", raw)
}
if raw.MixedPort != 7000 || !raw.DNS.Enable {
t.Errorf("config dropped a section the host patches: mixed-port %d, dns %t", raw.MixedPort, raw.DNS.Enable)
}
if base.Proxies != 2 || base.ProxyGroups != 1 || base.Rules != 3 {
t.Errorf("counts = %d/%d/%d, want 2/1/3", base.Proxies, base.ProxyGroups, base.Rules)
}
if want := []string{"match,DIRECT", "MATCH,g"}; !reflect.DeepEqual(base.MatchRules, want) {
t.Errorf("match rules = %v, want %v", base.MatchRules, want)
}
digest := sha256.Sum256([]byte(baseProfile))
if base.Digest != hex.EncodeToString(digest[:]) {
t.Errorf("digest = %s, want the sha256 of the file", base.Digest)
}
}
func TestHandleGetProfileOutboundsListsOnlyNamesAndTypes(t *testing.T) {
path := writeBaseProfile(t, baseProfile)
outbounds, err := handleGetProfileOutbounds(path)
if err != nil {
t.Fatalf("handleGetProfileOutbounds = %v", err)
}
want := &ProfileOutbounds{
Proxies: []ProfileOutbound{{Name: "a", Type: "socks5"}, {Name: "b", Type: "vless"}},
ProxyGroups: []ProfileOutbound{{Name: "g", Type: "select"}},
}
if !reflect.DeepEqual(outbounds, want) {
t.Errorf("outbounds = %+v, want %+v", outbounds, want)
}
}
func TestHandleBuildProfileWritesTheMergedConfigWhereTheHostReadsSpills(t *testing.T) {
withSpillHome(t, responseSpillThreshold)
path := writeBaseProfile(t, baseProfile)
base, err := handleGetProfileBase(path)
if err != nil {
t.Fatalf("handleGetProfileBase = %v", err)
}
overlay := overlayOf(t, base, []string{"DOMAIN,added.test,DIRECT"}, nil)
built, err := handleBuildProfile(&SetupBase{Path: path, Overlay: overlay})
if err != nil {
t.Fatalf("handleBuildProfile = %v", err)
}
if filepath.Dir(built) != spilledResponseDir() {
t.Errorf("built %s, want it in %s", built, spilledResponseDir())
}
cfg, err := loadConfig(built)
if err != nil {
t.Fatalf("loadConfig(built) = %v", err)
}
if got := ruleStrings(cfg); len(got) != 4 || got[0] != "Domain,added.test,DIRECT" {
t.Errorf("rules = %v, want the added rule before the profile's three", got)
}
}
func TestLoadConfigWithBasePutsTheAddedRulesFirstAndTheOverlayOverTheRest(t *testing.T) {
path := writeBaseProfile(t, baseProfile)
base, err := handleGetProfileBase(path)
if err != nil {
t.Fatalf("handleGetProfileBase = %v", err)
}
overlay := overlayOf(t, base, []string{"DOMAIN,added.test,DIRECT"}, map[string]any{
"mixed-port": 7890,
"dns": map[string]any{"enable": true, "nameserver": []string{"1.1.1.1"}},
})
configPath := filepath.Join(t.TempDir(), "config.yaml")
cfg, err := loadConfigWithBase(&SetupBase{Path: path, Overlay: overlay}, configPath)
if err != nil {
t.Fatalf("loadConfigWithBase = %v", err)
}
want := []string{
"Domain,added.test,DIRECT",
"DomainSuffix,example.com,g",
"Match,,DIRECT",
"Match,,g",
}
if got := ruleStrings(cfg); !reflect.DeepEqual(got, want) {
t.Errorf("rules = %v, want %v", got, want)
}
if cfg.General.MixedPort != 7890 {
t.Errorf("mixed-port = %d, want the overlay's", cfg.General.MixedPort)
}
if len(cfg.DNS.NameServer) != 1 || !strings.Contains(cfg.DNS.NameServer[0].Addr, "1.1.1.1") {
t.Errorf("nameservers = %+v, want only the overlay's", cfg.DNS.NameServer)
}
for _, name := range []string{"a", "b", "g"} {
if cfg.Proxies[name] == nil {
t.Errorf("proxy %s is missing", name)
}
}
if len(cfg.SubRules["sub"]) != 1 {
t.Errorf("sub-rules = %v, want the profile's", cfg.SubRules)
}
}
// The Android service and mihomo's controller reload config.yaml without the
// profile, and the anchor it was written with sits in a section the overlay drops.
func TestLoadConfigWithBaseWritesAConfigThatLoadsAlone(t *testing.T) {
path := writeBaseProfile(t, baseProfile)
base, err := handleGetProfileBase(path)
if err != nil {
t.Fatalf("handleGetProfileBase = %v", err)
}
configPath := filepath.Join(t.TempDir(), "config.yaml")
cfg, err := loadConfigWithBase(
&SetupBase{Path: path, Overlay: overlayOf(t, base, []string{"DOMAIN,added.test,DIRECT"}, nil)},
configPath,
)
if err != nil {
t.Fatalf("loadConfigWithBase = %v", err)
}
reloaded, err := loadConfig(configPath)
if err != nil {
t.Fatalf("loadConfig(config.yaml) = %v", err)
}
if got, want := ruleStrings(reloaded), ruleStrings(cfg); !reflect.DeepEqual(got, want) {
t.Errorf("reloaded rules = %v, want %v", got, want)
}
if len(reloaded.Proxies) != len(cfg.Proxies) {
t.Errorf("reloaded %d proxies, want %d", len(reloaded.Proxies), len(cfg.Proxies))
}
raw, err := handleGetConfig(configPath)
if err != nil {
t.Fatalf("handleGetConfig(config.yaml) = %v", err)
}
for _, proxy := range raw.Proxy {
if proxy["name"] != "b" {
continue
}
if got := proxy["reality-opts"].(map[string]any)["short-id"]; got != "0123" {
t.Errorf("short-id = %#v, want the literal 0123", got)
}
}
}
func TestLoadConfigWithBaseKeepsAnEmptyProfileOnTheDefaults(t *testing.T) {
path := writeBaseProfile(t, "")
cfg, err := loadConfigWithBase(
&SetupBase{Path: path, Overlay: `{"rules": ["MATCH,DIRECT"]}`},
filepath.Join(t.TempDir(), "config.yaml"),
)
if err != nil {
t.Fatalf("loadConfigWithBase = %v", err)
}
if got := ruleStrings(cfg); !reflect.DeepEqual(got, []string{"Match,,DIRECT"}) {
t.Errorf("rules = %v, want the added rule alone", got)
}
}
func TestLoadConfigWithBaseRejectsAProfileThatIsNotAMapping(t *testing.T) {
path := writeBaseProfile(t, "- a\n- b\n")
configPath := filepath.Join(t.TempDir(), "config.yaml")
if _, err := loadConfigWithBase(&SetupBase{Path: path, Overlay: "{}"}, configPath); err == nil {
t.Fatal("loadConfigWithBase accepted a list as a config")
}
if _, err := os.Stat(configPath); !os.IsNotExist(err) {
t.Errorf("stat config.yaml = %v, want the failed apply to leave it alone", err)
}
}
func TestLoadSetupConfigReadsConfigYamlWithoutABase(t *testing.T) {
home := t.TempDir()
previous := constant.Path.HomeDir()
constant.SetHomeDir(home)
t.Cleanup(func() { constant.SetHomeDir(previous) })
profile := "rules:\n - MATCH,REJECT\n"
if err := os.WriteFile(filepath.Join(home, "config.yaml"), []byte(profile), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := loadSetupConfig(defaultSetupParams())
if err != nil {
t.Fatalf("loadSetupConfig = %v", err)
}
if got := ruleStrings(cfg); !reflect.DeepEqual(got, []string{"Match,,REJECT"}) {
t.Errorf("rules = %v, want config.yaml's", got)
}
}
func TestEncodeConfigMappingKeepsEveryKeyAfterAnOpenEndedScalar(t *testing.T) {
rules := make([]string, encodeSliceLength*2+1)
for i := range rules {
rules[i] = fmt.Sprintf("DOMAIN,%d.test,DIRECT", i)
}
var mapping yaml.Node
if err := mapping.Encode(map[string]any{"rules": rules}); err != nil {
t.Fatalf("encode rules: %v", err)
}
note := []*yaml.Node{
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "a-note"},
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "kept\n\n", Style: yaml.LiteralStyle},
}
mapping.Content = append(note, mapping.Content...)
var out bytes.Buffer
if err := encodeConfigMapping(&out, &mapping); err != nil {
t.Fatalf("encodeConfigMapping = %v", err)
}
var decoded struct {
Note string `yaml:"a-note"`
Rules []string `yaml:"rules"`
}
if err := yaml.Unmarshal(out.Bytes(), &decoded); err != nil {
t.Fatalf("decode = %v", err)
}
if decoded.Note != "kept\n\n" {
t.Errorf("note = %q, want its trailing lines kept", decoded.Note)
}
if !reflect.DeepEqual(decoded.Rules, rules) {
t.Errorf("decoded %d rules, want all %d in order", len(decoded.Rules), len(rules))
}
}
+36 -13
View File
@@ -1,12 +1,12 @@
package core
import (
"net/netip"
"time"
"github.com/metacubex/http"
"github.com/metacubex/mihomo/adapter/provider"
P "github.com/metacubex/mihomo/component/process"
"github.com/metacubex/mihomo/config"
"github.com/metacubex/mihomo/constant"
"github.com/metacubex/mihomo/log"
"github.com/metacubex/mihomo/tunnel"
@@ -56,6 +56,9 @@ const (
crashMethod CoreMethod = "crash"
setupConfigMethod CoreMethod = "setupConfig"
getConfigMethod CoreMethod = "getConfig"
getProfileBaseMethod CoreMethod = "getProfileBase"
getProfileOutboundsMethod CoreMethod = "getProfileOutbounds"
buildProfileMethod CoreMethod = "buildProfile"
dumpRuleSetMethod CoreMethod = "dumpRuleSet"
compileRuleSetMethod CoreMethod = "compileRuleSet"
clearEffectMethod CoreMethod = "clearEffect"
@@ -72,8 +75,9 @@ const (
LoadedMessage MessageType = "loaded"
GeoUpdateMessage MessageType = "geoUpdate"
// Named after the Dart enum value, which is how CoreEvent.fromJson decodes it.
RouteChangedMessage MessageType = "routeChanged"
DialerLoopMessage MessageType = "dialerLoop"
RouteChangedMessage MessageType = "routeChanged"
DialerLoopMessage MessageType = "dialerLoop"
RequestTrafficMessage MessageType = "requestTraffic"
)
type Message struct {
@@ -95,6 +99,31 @@ type SetupParams struct {
SelectedMap map[string]string `json:"selected-map"`
TestURL string `json:"test-url"`
SkipCertVerify bool `json:"skip-cert-verify"`
Base *SetupBase `json:"base,omitempty"`
}
type SetupBase struct {
Path string `json:"path"`
Overlay string `json:"overlay"`
}
type ProfileBase struct {
Config *config.RawConfig `json:"config"`
Digest string `json:"digest"`
MatchRules []string `json:"match-rules"`
Proxies int `json:"proxies"`
ProxyGroups int `json:"proxy-groups"`
Rules int `json:"rules"`
}
type ProfileOutbounds struct {
Proxies []ProfileOutbound `json:"proxies"`
ProxyGroups []ProfileOutbound `json:"proxy-groups"`
}
type ProfileOutbound struct {
Name string `json:"name"`
Type string `json:"type"`
}
type UpdateParams struct {
@@ -117,16 +146,10 @@ type UpdateParams struct {
}
type tunSchema struct {
Enable bool `yaml:"enable" json:"enable"`
Device *string `yaml:"device" json:"device"`
Stack *constant.TUNStack `yaml:"stack" json:"stack"`
DNSHijack *[]string `yaml:"dns-hijack" json:"dns-hijack"`
AutoRoute *bool `yaml:"auto-route" json:"auto-route"`
RouteAddress *[]netip.Prefix `yaml:"route-address" json:"route-address,omitempty"`
MTU *uint32 `yaml:"mtu" json:"mtu"`
CongestionController *string `yaml:"congestion-controller" json:"congestion-controller"`
StrictRoute *bool `yaml:"strict-route" json:"strict-route"`
RouteExcludeAddress *[]netip.Prefix `yaml:"route-exclude-address" json:"route-exclude-address"`
Enable bool `yaml:"enable" json:"enable"`
Device *string `yaml:"device" json:"device"`
Stack *constant.TUNStack `yaml:"stack" json:"stack"`
AutoRoute *bool `yaml:"auto-route" json:"auto-route"`
}
type SideLoadParams struct {
+125
View File
@@ -0,0 +1,125 @@
package core
import (
"sync"
"time"
"github.com/metacubex/mihomo/tunnel/statistic"
)
// mihomo announces a connection only as it opens, and has no hook for its
// close; the request list learns the traffic that follows from these reports.
const requestTrafficInterval = time.Second
// A full bulk queue drops a report; a closed request holds its connection only this many retries.
const requestTrafficCloseRetries = 5
type RequestTraffic struct {
Id string `json:"id"`
Upload int64 `json:"upload"`
Download int64 `json:"download"`
}
type watchedRequest struct {
tracker statistic.Tracker
upload int64
download int64
// The tracker is announced before the manager stores it, so a request
// missing from the manager on its first report may still be opening.
fresh bool
closedReports int
}
type requestTrafficWatch struct {
mu sync.Mutex
requests map[string]*watchedRequest
running bool
}
var currentRequestTraffic = requestTrafficWatch{
requests: map[string]*watchedRequest{},
}
func (w *requestTrafficWatch) add(tracker statistic.Tracker) (start bool) {
info := tracker.Info()
w.mu.Lock()
defer w.mu.Unlock()
w.requests[tracker.ID()] = &watchedRequest{
tracker: tracker,
upload: info.UploadTotal.Load(),
download: info.DownloadTotal.Load(),
fresh: true,
}
start = !w.running
w.running = true
return start
}
func (w *requestTrafficWatch) collect(
isOpen func(id string) bool,
queue func([]RequestTraffic) (queued bool),
) (watching bool) {
w.mu.Lock()
defer w.mu.Unlock()
var changed []RequestTraffic
for id, request := range w.requests {
if request.closedReports > 0 || !request.fresh && !isOpen(id) {
request.closedReports++
}
request.fresh = false
info := request.tracker.Info()
upload, download := info.UploadTotal.Load(), info.DownloadTotal.Load()
if upload != request.upload || download != request.download {
changed = append(changed, RequestTraffic{Id: id, Upload: upload, Download: download})
}
}
queued := len(changed) == 0 || queue(changed)
if queued {
for _, traffic := range changed {
request := w.requests[traffic.Id]
request.upload, request.download = traffic.Upload, traffic.Download
}
}
for id, request := range w.requests {
if request.closedReports > 0 && (queued || request.closedReports > requestTrafficCloseRetries) {
delete(w.requests, id)
}
}
w.running = len(w.requests) > 0
return w.running
}
func (w *requestTrafficWatch) clear() {
w.mu.Lock()
defer w.mu.Unlock()
clear(w.requests)
w.running = false
}
func watchRequestTraffic(tracker statistic.Tracker) {
if currentRequestTraffic.add(tracker) {
go runRequestTrafficWatch()
}
}
func runRequestTrafficWatch() {
ticker := time.NewTicker(requestTrafficInterval)
defer ticker.Stop()
for range ticker.C {
if !hasEventListener() {
currentRequestTraffic.clear()
return
}
watching := currentRequestTraffic.collect(
func(id string) bool {
return statistic.DefaultManager.Get(id) != nil
},
func(changed []RequestTraffic) bool {
return sendMessage(RequestTrafficMessage, changed)
},
)
if !watching {
return
}
}
}
+139
View File
@@ -0,0 +1,139 @@
package core
import (
"io"
"net"
"testing"
"github.com/metacubex/mihomo/adapter/outbound"
"github.com/metacubex/mihomo/constant"
"github.com/metacubex/mihomo/tunnel/statistic"
)
type pipeTracker interface {
statistic.Tracker
io.Writer
}
func newPipeTracker(t *testing.T) pipeTracker {
t.Helper()
local, remote := net.Pipe()
go func() { _, _ = io.Copy(io.Discard, remote) }()
direct := outbound.NewDirectWithOption(outbound.DirectOption{Name: "DIRECT"})
tracker := statistic.NewTCPTracker(
outbound.NewConn(local, direct),
statistic.DefaultManager,
&constant.Metadata{},
nil,
0,
0,
false,
)
t.Cleanup(func() {
_ = tracker.Close()
_ = remote.Close()
})
return tracker
}
func collectDelivered(watch *requestTrafficWatch, isOpen func(string) bool) (changed []RequestTraffic, watching bool) {
watching = watch.collect(isOpen, func(traffic []RequestTraffic) bool {
changed = traffic
return true
})
return changed, watching
}
func TestRequestTrafficReportsChangesUntilTheRequestCloses(t *testing.T) {
tracker := newPipeTracker(t)
watch := requestTrafficWatch{requests: map[string]*watchedRequest{}}
open := true
isOpen := func(string) bool { return open }
if !watch.add(tracker) {
t.Fatal("the first request did not start the watch")
}
if changed, watching := collectDelivered(&watch, isOpen); len(changed) != 0 || !watching {
t.Fatalf("idle request: changed %v, watching %v", changed, watching)
}
if _, err := tracker.Write([]byte("hello")); err != nil {
t.Fatalf("write: %v", err)
}
changed, _ := collectDelivered(&watch, isOpen)
if len(changed) != 1 || changed[0].Id != tracker.ID() || changed[0].Upload != 5 {
t.Fatalf("after a write: %v", changed)
}
if changed, _ := collectDelivered(&watch, isOpen); len(changed) != 0 {
t.Fatalf("unchanged traffic reported again: %v", changed)
}
if _, err := tracker.Write([]byte("bye")); err != nil {
t.Fatalf("write: %v", err)
}
open = false
changed, watching := collectDelivered(&watch, isOpen)
if len(changed) != 1 || changed[0].Upload != 8 || watching {
t.Fatalf("closed request: changed %v, watching %v", changed, watching)
}
if !watch.add(tracker) {
t.Fatal("a request after the watch stopped did not restart it")
}
}
func TestRequestTrafficKeepsARequestTheManagerHasNotStoredYet(t *testing.T) {
tracker := newPipeTracker(t)
watch := requestTrafficWatch{requests: map[string]*watchedRequest{}}
closed := func(string) bool { return false }
watch.add(tracker)
if _, watching := collectDelivered(&watch, closed); !watching {
t.Fatal("a request announced before it was stored was dropped on its first report")
}
if _, watching := collectDelivered(&watch, closed); watching {
t.Fatal("a closed request was kept past its second report")
}
}
func TestRequestTrafficResendsTheLastTotalsOfAClosedRequestUntilQueued(t *testing.T) {
tracker := newPipeTracker(t)
watch := requestTrafficWatch{requests: map[string]*watchedRequest{}}
open := true
isOpen := func(string) bool { return open }
dropped := func([]RequestTraffic) bool { return false }
watch.add(tracker)
collectDelivered(&watch, isOpen)
if _, err := tracker.Write([]byte("bye")); err != nil {
t.Fatalf("write: %v", err)
}
open = false
if !watch.collect(isOpen, dropped) {
t.Fatal("a closed request whose last totals were dropped was let go")
}
changed, watching := collectDelivered(&watch, isOpen)
if len(changed) != 1 || changed[0].Upload != 3 || watching {
t.Fatalf("after the dropped report: changed %v, watching %v", changed, watching)
}
}
func TestRequestTrafficGivesUpOnAClosedRequestItCannotReport(t *testing.T) {
tracker := newPipeTracker(t)
watch := requestTrafficWatch{requests: map[string]*watchedRequest{}}
closed := func(string) bool { return false }
dropped := func([]RequestTraffic) bool { return false }
watch.add(tracker)
watch.collect(closed, dropped)
if _, err := tracker.Write([]byte("bye")); err != nil {
t.Fatalf("write: %v", err)
}
for report := 1; report <= requestTrafficCloseRetries; report++ {
if !watch.collect(closed, dropped) {
t.Fatalf("let go after %d dropped reports", report)
}
}
if watch.collect(closed, dropped) {
t.Fatal("a closed request outlived its retries")
}
}
+85
View File
@@ -0,0 +1,85 @@
package core
import (
"os"
"path/filepath"
"github.com/metacubex/mihomo/constant"
)
// Android copies a reply into the Java heap three times and the desktop IPC
// frame stops at maxIPCFrameSize. The directory name is mirrored in Dart.
const (
responseSpillThreshold = 1 << 20
spilledResponseDirName = "responses"
)
var spillThreshold = responseSpillThreshold
func spilledResponseDir() string {
return filepath.Join(constant.Path.HomeDir(), spilledResponseDirName)
}
// The host deletes each file as soon as it reads it, which a directory an
// elevated Core just made would refuse until the debounced reclaim.
func makeSpilledResponseDir() (string, error) {
dir := spilledResponseDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
reclaimDirOwnership(dir)
return dir, nil
}
func spillResponse(id string, data []byte) []byte {
if len(data) <= spillThreshold {
return data
}
path, err := writeSpilledResponse(data)
if err != nil {
logError("spill response %s: %v", id, err)
return data
}
return encodeResponse(MethodResponse{ID: id, File: path})
}
func writeSpilledResponse(data []byte) (path string, err error) {
dir, err := makeSpilledResponseDir()
if err != nil {
return "", err
}
file, err := os.CreateTemp(dir, "*.json")
if err != nil {
return "", err
}
path = file.Name()
defer func() {
if err != nil {
_ = os.Remove(path)
}
}()
// An elevated Core still answers a host running as the app user.
if err := file.Chmod(0o644); err != nil {
_ = file.Close()
return "", err
}
if _, err := file.Write(data); err != nil {
_ = file.Close()
return "", err
}
if err := file.Close(); err != nil {
return "", err
}
scheduleReclaimOwnership()
return path, nil
}
func clearSpilledResponses() {
entries, err := os.ReadDir(spilledResponseDir())
if err != nil {
return
}
for _, entry := range entries {
_ = os.Remove(filepath.Join(spilledResponseDir(), entry.Name()))
}
}
+104
View File
@@ -0,0 +1,104 @@
package core
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/metacubex/mihomo/constant"
)
func withSpillHome(t *testing.T, threshold int) string {
t.Helper()
previousHome := constant.Path.HomeDir()
previousThreshold := spillThreshold
home := t.TempDir()
constant.SetHomeDir(home)
spillThreshold = threshold
t.Cleanup(func() {
constant.SetHomeDir(previousHome)
spillThreshold = previousThreshold
})
return home
}
func getConfigCall(t *testing.T, id string) *MethodCall {
t.Helper()
path := filepath.Join(t.TempDir(), "profile.yaml")
if err := os.WriteFile(path, []byte("mode: rule\nrules:\n - MATCH,DIRECT\n"), 0o600); err != nil {
t.Fatalf("write profile: %v", err)
}
arguments, _ := json.Marshal(path)
return &MethodCall{ID: id, Method: getConfigMethod, Arguments: arguments}
}
func TestHandleMethodCallSpillsALargeResultToAFile(t *testing.T) {
home := withSpillHome(t, 64)
frame := handleMethodCall(getConfigCall(t, "7"))
var envelope MethodResponse
if err := json.Unmarshal(frame, &envelope); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if envelope.ID != "7" || envelope.Result != nil || envelope.Error != nil {
t.Fatalf("envelope = %+v, want only the id and the file", envelope)
}
if filepath.Dir(envelope.File) != filepath.Join(home, spilledResponseDirName) {
t.Fatalf("file = %s, want it under the home directory's spill directory", envelope.File)
}
data, err := os.ReadFile(envelope.File)
if err != nil {
t.Fatalf("read spilled response: %v", err)
}
var spilled struct {
ID string `json:"id"`
Result map[string]any `json:"result"`
}
if err := json.Unmarshal(data, &spilled); err != nil {
t.Fatalf("spilled response is not valid JSON: %v", err)
}
if spilled.ID != "7" || spilled.Result["mode"] != "rule" {
t.Errorf("spilled response = %+v, want the full answer to call 7", spilled)
}
if runtime.GOOS != "windows" {
info, _ := os.Stat(envelope.File)
if info.Mode().Perm() != 0o644 {
t.Errorf("mode = %v, want the host to be able to read it", info.Mode().Perm())
}
}
}
func TestHandleMethodCallAnswersInlineUnderTheThreshold(t *testing.T) {
home := withSpillHome(t, responseSpillThreshold)
frame := handleMethodCall(getConfigCall(t, "8"))
var envelope MethodResponse
if err := json.Unmarshal(frame, &envelope); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if envelope.File != "" || envelope.Result == nil {
t.Errorf("envelope = %+v, want the result inline", envelope)
}
if _, err := os.Stat(filepath.Join(home, spilledResponseDirName)); !os.IsNotExist(err) {
t.Errorf("stat spill directory = %v, want nothing written", err)
}
}
func TestClearSpilledResponsesKeepsTheDirectory(t *testing.T) {
home := withSpillHome(t, 64)
handleMethodCall(getConfigCall(t, "9"))
clearSpilledResponses()
entries, err := os.ReadDir(filepath.Join(home, spilledResponseDirName))
if err != nil {
t.Fatalf("read spill directory: %v", err)
}
if len(entries) != 0 {
t.Errorf("entries = %v, want none left", entries)
}
}
+16 -57
View File
@@ -6,17 +6,14 @@ import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/window.dart';
import 'package:fl_clash/bootstrap.dart';
import 'package:fl_clash/common/system_dns.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/manager/hotkey_manager.dart';
import 'package:fl_clash/manager/manager.dart';
import 'package:fl_clash/plugins/app.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/focus.dart';
import 'package:fl_clash/widgets/keyboard_inset_hold.dart';
import 'package:fl_clash/ui/ui.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -62,17 +59,6 @@ class Application extends ConsumerStatefulWidget {
ConsumerState<Application> createState() => ApplicationState();
}
const _actionIconTheme = ActionIconThemeData(
backButtonIconBuilder: _backButtonIcon,
closeButtonIconBuilder: _closeButtonIcon,
);
Widget _backButtonIcon(BuildContext context) =>
GlyphIcon(AppGlyphs.backFor(Theme.of(context).platform));
Widget _closeButtonIcon(BuildContext context) =>
const GlyphIcon(AppGlyphs.close);
class ApplicationState extends ConsumerState<Application> {
Timer? _autoUpdateProfilesTaskTimer;
bool _preHasVpn = false;
@@ -81,19 +67,6 @@ class ApplicationState extends ConsumerState<Application> {
? ProcessSignal.sigterm.watch()
: null;
final _pageTransitionsTheme = const PageTransitionsTheme(
builders: <TargetPlatform, PageTransitionsBuilder>{
TargetPlatform.android: commonSharedXPageTransitions,
TargetPlatform.windows: commonSharedXPageTransitions,
TargetPlatform.linux: commonSharedXPageTransitions,
TargetPlatform.macOS: commonSharedXPageTransitions,
},
);
ColorScheme _getAppColorScheme({required Brightness brightness}) {
return ref.read(genColorSchemeProvider(brightness));
}
@override
void initState() {
super.initState();
@@ -173,28 +146,27 @@ class ApplicationState extends ConsumerState<Application> {
appSettingProvider.select((state) => state.locale),
);
final themeProps = ref.watch(themeSettingProvider);
final fontFamilyFallback = appFontFamilyFallback(
platform: defaultTargetPlatform,
locales: [
?getLocaleForString(locale),
...WidgetsBinding.instance.platformDispatcher.locales,
],
fontFamily: themeProps.fontFamily,
);
return MaterialApp(
debugShowCheckedModeBanner: false,
navigatorKey: globalState.navigatorKey,
onNavigationNotification: (_) => true,
localizationsDelegates: const [
AppLocalizations.delegate,
AppUiLocalizations.delegate,
...GlobalMaterialLocalizations.delegates,
],
builder: (context, child) {
return buildManagerStack(
isDesktop: system.isDesktop,
onConnectivityChanged: _handleConnectivityChanged,
terminateSignals: _terminateSignals,
child: RemoteFocusAdapter(enabled: system.isTV, child: child!),
return ThemeCrossFade(
child: buildManagerStack(
isDesktop: system.isDesktop,
onConnectivityChanged: _handleConnectivityChanged,
terminateSignals: _terminateSignals,
child: RemoteFocusAdapter(
enabled: system.isTV,
floatingKeyboard: app?.floatingKeyboard,
child: child!,
),
),
);
},
scrollBehavior: const BaseScrollBehavior(),
@@ -202,22 +174,9 @@ class ApplicationState extends ConsumerState<Application> {
locale: getLocaleForString(locale),
supportedLocales: AppLocalizations.delegate.supportedLocales,
themeMode: themeProps.themeMode,
theme: ThemeData(
useMaterial3: true,
pageTransitionsTheme: _pageTransitionsTheme,
actionIconTheme: _actionIconTheme,
colorScheme: _getAppColorScheme(brightness: Brightness.light),
fontFamily: themeProps.fontFamily,
fontFamilyFallback: fontFamilyFallback,
).withAppShapes,
darkTheme: ThemeData(
useMaterial3: true,
pageTransitionsTheme: _pageTransitionsTheme,
actionIconTheme: _actionIconTheme,
colorScheme: _getAppColorScheme(brightness: Brightness.dark),
fontFamily: themeProps.fontFamily,
fontFamilyFallback: fontFamilyFallback,
).withAppShapes,
theme: watchAppTheme(ref, Brightness.light),
darkTheme: watchAppTheme(ref, Brightness.dark),
themeAnimationStyle: AnimationStyle.noAnimation,
home: KeyboardInsetHold(child: child!),
);
},
-19
View File
@@ -104,7 +104,6 @@ class Bootstrap {
var config = await migration.run();
_bootDecision = await bootGuard.evaluate(
profileId: config.currentProfileId,
crashlyticsEnabled: config.appSettingProps.crashlytics,
);
if (_bootDecision.recovery == BootRecovery.clearProfile) {
config = config.copyWith(currentProfileId: null);
@@ -180,7 +179,6 @@ class Bootstrap {
return;
}
await _showCrashRecoveryTip();
await _showCrashlyticsTip();
await _container.read(coreActionProvider.notifier).startCore();
if (!_bootDecision.isDegraded) {
await _container.read(setupActionProvider.notifier).initStatus();
@@ -237,23 +235,6 @@ class Bootstrap {
await _container.read(systemActionProvider.notifier).handleExit(false);
}
Future<void> _showCrashlyticsTip() async {
if (!system.isAndroid) return;
if (_container.read(
appSettingProvider.select((state) => state.crashlyticsTip),
)) {
return;
}
await dialogs.showMessage(
title: currentAppLocalizations.dataCollectionTip,
cancelable: false,
message: TextSpan(text: currentAppLocalizations.dataCollectionContent),
);
_container
.read(appSettingProvider.notifier)
.update((state) => state.copyWith(crashlyticsTip: true));
}
/// Android's exit only finishes the activity, so Dart keeps running after
/// [SystemAction.handleExit] returns and must not go on to start the app.
Future<bool> _handlerDisclaimer() async {
+69 -2
View File
@@ -4,11 +4,78 @@ import 'package:fl_clash/common/network_error.dart';
import 'package:fl_clash/core/desktop/launch_policy.dart';
import 'package:fl_clash/core/method.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'dart:ui';
import 'package:fl_clash/ui/l10n.dart';
import 'package:flutter/widgets.dart';
final currentAppLocalizations = AppLocalizations.current;
class AppUiLocalizations implements UiLocalizations {
const AppUiLocalizations(this._localizations);
static const LocalizationsDelegate<UiLocalizations> delegate =
_AppUiLocalizationsDelegate();
final AppLocalizations _localizations;
@override
String get back => _localizations.back;
@override
String get cancel => _localizations.cancel;
@override
String get clearSearch => _localizations.clearSearch;
@override
String get close => _localizations.close;
@override
String get hidePassword => _localizations.hidePassword;
@override
String get more => _localizations.more;
@override
String get noSearchResults => _localizations.noSearchResults;
@override
String get reset => _localizations.reset;
@override
String get search => _localizations.search;
@override
String get showLess => _localizations.showLess;
@override
String get showMore => _localizations.showMore;
@override
String get showPassword => _localizations.showPassword;
@override
String get sort => _localizations.sort;
@override
String get submit => _localizations.submit;
}
class _AppUiLocalizationsDelegate
extends LocalizationsDelegate<UiLocalizations> {
const _AppUiLocalizationsDelegate();
@override
bool isSupported(Locale locale) =>
AppLocalizations.delegate.isSupported(locale);
@override
Future<UiLocalizations> load(Locale locale) =>
AppLocalizations.load(locale).then(AppUiLocalizations.new);
@override
bool shouldReload(_AppUiLocalizationsDelegate old) => false;
}
String? coreLaunchBlockedMessage(
Object error,
AppLocalizations appLocalizations,
+2 -13
View File
@@ -11,7 +11,6 @@ class BootGuard {
final Future<BootRecord?> Function() _readRecord;
final Future<void> Function(BootRecord record) _writeRecord;
final Future<AppExitInfo?> Function() _readExitInfo;
final Future<bool> Function() _readCrashReport;
final int Function() _now;
BootDecision _decision = const BootDecision();
@@ -21,34 +20,24 @@ class BootGuard {
Future<BootRecord?> Function()? readRecord,
Future<void> Function(BootRecord record)? writeRecord,
Future<AppExitInfo?> Function()? readExitInfo,
Future<bool> Function()? readCrashReport,
int Function()? now,
}) : _supported = supported ?? system.isAndroid,
_readRecord = readRecord ?? preferences.getBootRecord,
_writeRecord = writeRecord ?? preferences.saveBootRecord,
_readExitInfo = readExitInfo ?? system.lastExitInfo,
_readCrashReport = readCrashReport ?? system.didCrashOnPreviousExecution,
_now = now ?? _currentMilliseconds;
static int _currentMilliseconds() => DateTime.now().millisecondsSinceEpoch;
BootDecision get decision => _decision;
Future<BootDecision> evaluate({
required int? profileId,
required bool crashlyticsEnabled,
}) async {
Future<BootDecision> evaluate({required int? profileId}) async {
if (!_supported) {
return _decision;
}
final record = await _readRecord();
final exitInfo = await _readExitInfo();
final crashReported = crashlyticsEnabled && await _readCrashReport();
final decision = resolveBootDecision(
record: record,
exitInfo: exitInfo,
crashReported: crashReported,
);
final decision = resolveBootDecision(record: record, exitInfo: exitInfo);
if (decision.isDegraded) {
commonPrint.log(
'Previous launch did not finish: $decision',
+1 -2
View File
@@ -197,7 +197,6 @@ class BootDecision {
BootDecision resolveBootDecision({
required BootRecord? record,
required AppExitInfo? exitInfo,
required bool crashReported,
}) {
if (record == null || record.stage != BootStage.starting) {
return const BootDecision();
@@ -219,7 +218,7 @@ BootDecision resolveBootDecision({
: BootRecovery.skipAutoSetup,
failureCount: failureCount,
failedProfileId: record.profileId,
crashConfirmed: (reason?.isCrash ?? false) || crashReported,
crashConfirmed: reason?.isCrash ?? false,
exitReason: reason,
);
}
-136
View File
@@ -1,136 +0,0 @@
import 'dart:math';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:material_color_utilities/hct/hct.dart';
import 'package:material_ui/material_ui.dart';
extension ColorExtension on Color {
Color get opacity80 {
return withValues(alpha: 0.8);
}
Color get opacity60 {
return withValues(alpha: 0.6);
}
Color get opacity50 {
return withValues(alpha: 0.5);
}
Color get opacity38 {
return withValues(alpha: 0.38);
}
Color get opacity30 {
return withValues(alpha: 0.3);
}
Color get opacity12 {
return withValues(alpha: 0.12);
}
Color get opacity15 {
return withValues(alpha: 0.15);
}
Color get opacity10 {
return withValues(alpha: 0.1);
}
Color get opacity3 {
return withValues(alpha: 0.03);
}
Color get opacity0 {
return withValues(alpha: 0);
}
int get value32bit {
return _floatToInt8(a) << 24 |
_floatToInt8(r) << 16 |
_floatToInt8(g) << 8 |
_floatToInt8(b) << 0;
}
int get alpha8bit => (0xff000000 & value32bit) >> 24;
int get red8bit => (0x00ff0000 & value32bit) >> 16;
int get green8bit => (0x0000ff00 & value32bit) >> 8;
int get blue8bit => (0x000000ff & value32bit) >> 0;
int _floatToInt8(double x) {
return (x * 255.0).round() & 0xff;
}
Color lighten([double amount = 10]) {
if (amount <= 0) return this;
if (amount > 100) return Colors.white;
final HSLColor hsl = this == const Color(0xFF000000)
? HSLColor.fromColor(this).withSaturation(0)
: HSLColor.fromColor(this);
return hsl
.withLightness(min(1, max(0, hsl.lightness + amount / 100)))
.toColor();
}
String get hex {
final value = toARGB32();
final red = (value >> 16) & 0xFF;
final green = (value >> 8) & 0xFF;
final blue = value & 0xFF;
return '#${red.toRadixString(16).padLeft(2, '0')}'
'${green.toRadixString(16).padLeft(2, '0')}'
'${blue.toRadixString(16).padLeft(2, '0')}'
.toUpperCase();
}
Color blendDarken(BuildContext context, {double factor = 0.1}) {
final brightness = Theme.of(context).brightness;
return Color.lerp(
this,
brightness == Brightness.dark ? Colors.white : Colors.black,
factor,
)!;
}
}
extension ColorSchemeExtension on ColorScheme {
ColorScheme toPureBlack(bool isPureBlack) {
if (!isPureBlack || brightness != Brightness.dark) {
return this;
}
final shift = Hct.fromInt(surface.toARGB32()).tone;
Color lower(Color color) {
final hct = Hct.fromInt(color.toARGB32());
return Color(
Hct.from(hct.hue, hct.chroma, max(0, hct.tone - shift)).toInt(),
);
}
return copyWith(
surface: Colors.black,
surfaceDim: Colors.black,
surfaceContainerLowest: Colors.black,
surfaceContainerLow: lower(surfaceContainerLow),
surfaceContainer: lower(surfaceContainer),
surfaceContainerHigh: lower(surfaceContainerHigh),
surfaceContainerHighest: lower(surfaceContainerHighest),
surfaceBright: lower(surfaceBright),
);
}
Color get modalScrim => scrim.withValues(alpha: 0.32);
Color get success => Colors.green.harmonizeWith(primary);
Color get warning => Colors.orange.harmonizeWith(primary);
Color? delayColor(int? delay) {
if (delay == null) return null;
if (delay < 0) return error;
if (delay < 600) return success;
return warning;
}
}
-8
View File
@@ -3,7 +3,6 @@ export 'app_ports.dart';
export 'backup.dart';
export 'changelog.dart';
export 'cidr.dart';
export 'color.dart';
export 'compute.dart';
export 'constant.dart';
export 'context.dart';
@@ -14,7 +13,6 @@ export 'exception.dart';
export 'feature.dart';
export 'file.dart';
export 'fixed.dart';
export 'font.dart';
export 'function.dart';
export 'future.dart';
export 'http.dart';
@@ -30,10 +28,7 @@ export 'link.dart';
export 'linux_elevation.dart';
export 'log_payload.dart';
export 'lock.dart';
export 'measure.dart';
export 'mixin.dart';
export 'motion.dart';
export 'navigator.dart';
export 'network.dart';
export 'network_error.dart';
export 'num.dart';
@@ -46,15 +41,12 @@ export 'protocol.dart';
export 'provider_reader.dart';
export 'proxy.dart';
export 'request.dart';
export 'scroll.dart';
export 'search.dart';
export 'shape.dart';
export 'snowflake.dart';
export 'string.dart';
export 'system.dart';
export 'system_fonts.dart';
export 'task.dart';
export 'task_pool.dart';
export 'text.dart';
export 'webdav.dart';
export 'yaml.dart';
+6 -4
View File
@@ -81,7 +81,10 @@ List<Group> computeHideTimeout({
selectedMap[group.name] ?? '',
);
final visible = group.all.where((proxy) {
if (proxy.name == selectedName) {
// A group's delay is its pick's, and hiding it would take away the only
// way to choose the group where the mode does not show it on its own.
if (proxy.name == selectedName ||
allGroups.getGroup(proxy.name) != null) {
return true;
}
final state = realStates.putIfAbsent(
@@ -109,10 +112,9 @@ SelectedProxyState getRealSelectedProxyState(
required Map<String, String> selectedMap,
}) {
if (state.proxyName.isEmpty) return state;
final index = groups.indexWhere((element) => element.name == state.proxyName);
final group = groups.getGroup(state.proxyName);
final newState = state.copyWith(group: true);
if (index == -1) return newState;
final group = groups[index];
if (group == null) return newState;
final currentSelectedName = group.getCurrentSelectedName(
selectedMap[newState.proxyName] ?? '',
);
+9 -28
View File
@@ -12,7 +12,6 @@ import 'package:material_ui/material_ui.dart';
const appName = 'FlClash';
const appHelperService = 'FlClashHelperService';
const coreManifestName = 'manifest.json';
const coreName = 'clash.meta';
const browserUa =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const packageName = 'com.follow.clash';
@@ -27,22 +26,6 @@ const helperProtocolVersionHeader = 'x-flclash-helper-protocol';
final helperProtocolVersion = Platform.isWindows ? '8' : '6';
const maxTextScale = 1.4;
const minTextScale = 0.8;
final baseInfoEdgeInsets = EdgeInsets.symmetric(
vertical: 16.mAp,
horizontal: 16.mAp,
);
final listHeaderPadding = EdgeInsets.only(
left: 16.mAp,
right: 16.mAp,
top: 24.mAp,
bottom: 8.mAp,
);
const listRowMinHeight = 56.0;
const listRowVerticalPadding = 10.0;
const pageToolbarHeight = 64.0;
const sheetToolbarHeight = 48.0;
const sheetAppBarHeight = 68.0;
const watchExecution = false;
@@ -99,9 +82,6 @@ const coreConnectionWaitDuration = Duration(seconds: 10);
const maxConcurrentDelayTests = 32;
const maxConcurrentImports = 4;
const maxConcurrentIconLoads = 8;
const animateDuration = Duration(milliseconds: 100);
const midDuration = Duration(milliseconds: 200);
const commonDuration = Duration(milliseconds: 300);
const renderThrottleDuration = Duration(milliseconds: 300);
const defaultUpdateDuration = Duration(days: 1);
@@ -128,7 +108,6 @@ const configKey = 'config';
const systemDnsRecordKey = 'system_dns_record';
const bootRecordKey = 'boot_record';
const defaultSystemDnsFallback = '223.5.5.5';
const double dialogCommonWidth = 300;
const repository = 'chen08209/FlClash';
const maxMobileWidth = 600;
const maxLaptopWidth = 840;
@@ -171,7 +150,7 @@ const proxiesListStoreKey = PageStorageKey<String>('proxies_list');
const toolsStoreKey = PageStorageKey<String>('tools');
const profilesStoreKey = PageStorageKey<String>('profiles');
const defaultPrimaryColor = 0XFFD8C0C3;
const defaultPrimaryColor = 0xFF007AFF;
const maxLogsLength = 5000;
const maxRequestsLength = 2000;
@@ -182,14 +161,16 @@ const pausedMaxDnsQueriesLength = maxDnsQueriesLength * 2;
const trafficSampleLength = 30;
// Apple's system colors, in the order of the macOS accent colors.
const defaultPrimaryColors = [
0xFF795548,
0xFF03A9F4,
0xFFFFFF00,
0XFFBBC9CC,
0XFFABD397,
defaultPrimaryColor,
0XFF665390,
0xFFAF52DE,
0xFFFF2D55,
0xFFFF3B30,
0xFFFF9500,
0xFFFFCC00,
0xFF34C759,
0xFF8E8E93,
];
const scriptTemplate = '''
+1 -113
View File
@@ -1,55 +1,10 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/manager/status_manager.dart';
import 'package:fl_clash/models/state.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/widgets/inherited.dart';
import 'package:fl_clash/widgets/scaffold.dart';
import 'package:fl_clash/widgets/sheet.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
extension BuildContextExtension on BuildContext {
CommonScaffoldState? get commonScaffoldState {
return findAncestorStateOfType<CommonScaffoldState>();
}
bool get isMobileView {
return ProviderScope.containerOf(
this,
listen: false,
).read(isMobileViewProvider);
}
void safeNestedPop<T extends Object?>([T? result]) {
final nestedPop = SheetProvider.of(this)?.nestedNavigatorPop;
if (nestedPop != null) {
return nestedPop(result);
} else {
return Navigator.of(this).pop(result);
}
}
bool get isInBottomSheet =>
SheetProvider.of(this)?.type == SheetType.bottomSheet;
/// How far a page's app bar reaches over the top of its body.
///
/// Where a page builds the body it hands its scaffold, outside that body,
/// this is where the bar will end: a toolbar below the status bar.
double get appBarInset =>
FloatingBarScope.of(this) ??
(isInBottomSheet
? sheetAppBarHeight
: MediaQuery.paddingOf(this).top + pageToolbarHeight);
/// Where a scaffold body's content starts: a gap clear of the bar; a bottom
/// sheet's header already trails its own gap.
double get contentTopPadding =>
TopInsetScope.of(this) ??
(isInBottomSheet ? sheetAppBarHeight : appBarInset + 12);
extension AppContextExtension on BuildContext {
void showNotifier(
String text, {
MessageLevel level = MessageLevel.info,
@@ -62,72 +17,5 @@ extension BuildContextExtension on BuildContext {
);
}
void showSnackBar(String message, {SnackBarAction? action}) {
final width = MediaQuery.sizeOf(this).width;
EdgeInsets margin;
if (width < 600) {
margin = const EdgeInsets.only(bottom: 16, right: 16, left: 16);
} else {
margin = EdgeInsets.only(bottom: 16, left: 16, right: width - 316);
}
ScaffoldMessenger.of(this).showSnackBar(
SnackBar(
action: action,
content: Text(message),
behavior: SnackBarBehavior.floating,
duration: const Duration(milliseconds: 1500),
margin: margin,
),
);
}
ColorScheme get colorScheme => Theme.of(this).colorScheme;
bool get disableAnimations => MediaQuery.disableAnimationsOf(this);
Duration motionDuration(Duration duration) =>
disableAnimations ? Duration.zero : duration;
TextTheme get textTheme => Theme.of(this).textTheme;
/// Tighter than Material's 24 dp line so a row with a subtitle stays compact.
TextStyle? get listTitleStyle => textTheme.bodyLarge?.copyWith(
height: 22 / 16,
color: colorScheme.onSurface,
);
TextStyle? get listSubtitleStyle => textTheme.bodyMedium?.copyWith(
height: 20 / 14,
color: colorScheme.onSurfaceVariant,
);
TextStyle? get listCaptionStyle => textTheme.bodySmall
?.adjustSize(1)
.copyWith(color: colorScheme.onSurfaceVariant);
TextStyle? get sectionHeaderStyle => textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurfaceVariant.opacity80,
);
AppLocalizations get appLocalizations => AppLocalizations.of(this);
T? findLastStateOfType<T extends State>() {
T? state;
void visitor(Element element) {
if (!element.mounted) {
return;
}
if (element is StatefulElement) {
if (element.state is T) {
state = element.state as T;
}
}
element.visitChildren(visitor);
}
visitor(this as Element);
return state;
}
}
-33
View File
@@ -1,6 +1,3 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:fl_clash/enum/enum.dart';
import 'print.dart';
@@ -147,33 +144,3 @@ List<Object?> _salvageItems(
}
return kept;
}
class Uint8ListToListIntConverter extends Converter<Uint8List, List<int>> {
@override
List<int> convert(Uint8List input) {
return input.toList();
}
@override
Sink<Uint8List> startChunkedConversion(Sink<List<int>> sink) {
return _Uint8ListToListIntConverterSink(sink);
}
}
class _Uint8ListToListIntConverterSink implements Sink<Uint8List> {
const _Uint8ListToListIntConverterSink(this._target);
final Sink<List<int>> _target;
@override
void add(Uint8List data) {
_target.add(data.toList());
}
@override
void close() {
_target.close();
}
}
final uint8ListToListIntConverter = Uint8ListToListIntConverter();
+29 -68
View File
@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:animations/animations.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
@@ -11,22 +10,34 @@ import 'package:url_launcher/url_launcher.dart';
class Dialogs {
Dialogs._();
BuildContext get _context => rootNavigatorKey.currentContext!;
// Focus sits in the route the user acts in, so an alert raised from a form
// sheet, or a page pushed inside one, opens over it, beside the navigation
// as the sheet sits.
BuildContext get _context {
var context = FocusManager.instance.primaryFocus?.context;
while (context != null) {
final route = ModalRoute.of(context);
if (route is AlertHostRoute) {
final host = route.navigator;
if (host != null && host.mounted) {
return host.context;
}
break;
}
context = route?.navigator?.context;
}
return rootNavigatorKey.currentContext!;
}
Future<T?> showCommonDialog<T>({
required Widget child,
BuildContext? context,
bool? dismissible,
}) async {
final host = context ?? _context;
return showModal<T>(
useRootNavigator: false,
context: host,
configuration: FadeScaleTransitionConfiguration(
barrierColor: host.colorScheme.modalScrim,
barrierDismissible: dismissible ?? true,
),
builder: (_) => child,
return showFloatingDialog<T>(
context: context ?? _context,
child: child,
dismissible: dismissible ?? true,
);
}
@@ -38,6 +49,7 @@ class Dialogs {
String? cancelText,
bool cancelable = true,
bool? dismissible,
bool destructive = false,
}) async {
return showCommonDialog<bool>(
context: context,
@@ -47,6 +59,7 @@ class Dialogs {
final appLocalizations = context.appLocalizations;
return CommonDialog(
title: title ?? appLocalizations.tip,
destructive: destructive,
actions: [
if (cancelable)
TextButton(
@@ -62,18 +75,9 @@ class Dialogs {
child: Text(confirmText ?? appLocalizations.confirm),
),
],
child: Container(
width: 300,
constraints: const BoxConstraints(maxHeight: 200),
child: SingleChildScrollView(
child: SelectableText.rich(
TextSpan(
style: Theme.of(context).textTheme.labelLarge,
children: [message],
),
style: const TextStyle(overflow: TextOverflow.visible),
),
),
child: SelectableText.rich(
TextSpan(children: [message]),
style: const TextStyle(overflow: TextOverflow.visible),
),
);
},
@@ -135,49 +139,6 @@ class Dialogs {
);
}
Future<NamedUrl?> showNamedUrlInput({
required String title,
String label = '',
String url = '',
FormFieldValidator<String>? labelValidator,
FormFieldValidator<String>? urlValidator,
}) async {
final res = await showCommonDialog<List<NamedUrl>>(
child: NamedUrlDialog(
title: title,
label: label,
url: url,
labelValidator: labelValidator,
urlValidator: urlValidator,
),
);
return res?.single;
}
/// A batch skips the urls in [existingUrls].
Future<List<NamedUrl>?> showNamedUrlsInput({
required String title,
String? urlLabel,
String? batchTip,
FormFieldValidator<String>? labelValidator,
FormFieldValidator<String>? urlValidator,
Set<String> existingUrls = const {},
bool withAgeSecretKey = false,
}) {
return showCommonDialog<List<NamedUrl>>(
child: NamedUrlDialog(
title: title,
urlLabel: urlLabel,
batchTip: batchTip,
labelValidator: labelValidator,
urlValidator: urlValidator,
batch: true,
existingUrls: existingUrls,
withAgeSecretKey: withAgeSecretKey,
),
);
}
void showNotifier(
String text, {
MessageLevel level = MessageLevel.info,
@@ -247,10 +208,10 @@ class _FailureDetailsSheet extends StatelessWidget {
horizontal: 16,
).copyWith(top: context.contentTopPadding, bottom: 20),
itemCount: groups.length,
separatorBuilder: (_, _) => SizedBox(height: 24.mAp),
separatorBuilder: (_, _) => SizedBox(height: 24.mAp(context)),
itemBuilder: (_, index) {
final MapEntry(key: message, value: labels) = groups[index];
return generateSectionV3(
return generateSection(
items: [
for (final label in labels)
DecorationListItem(
+4 -3
View File
@@ -1,6 +1,7 @@
/// Features whose code stays but that ship switched off. A disabled feature
/// hides its setting and ignores the stored value, so switching it back on
/// restores what the user had chosen.
/// Build-time switches for features whose code stays either way, each
/// defaulting to how it ships. A disabled feature with a setting hides it and
/// ignores the stored value, so switching it back on restores what the user
/// had chosen.
class Feature {
const Feature({
this.sidebarBlur = const bool.fromEnvironment('FEATURE_SIDEBAR_BLUR'),
+16 -2
View File
@@ -32,6 +32,12 @@ class FixedList<T> {
return FixedList._(maxLength, _list, _revision + 1);
}
FixedList<T> replace(Map<int, T> items) {
items.forEach((index, item) => _list[index] = item);
_snapshot = null;
return FixedList._(maxLength, _list, _revision + 1);
}
List<T> get list => _snapshot ??= List.unmodifiable(_list);
int get length => _list.length;
@@ -54,11 +60,19 @@ class FixedList<T> {
/// Restores the head that [latest] trimmed off [current] so a paused view
/// stays anchored; the result is capped at [limit] from the tail.
List<T> retainTrimmedHead<T>(List<T> current, List<T> latest, int limit) {
List<T> retainTrimmedHead<T>(
List<T> current,
List<T> latest,
int limit, {
Object Function(T item)? keyOf,
}) {
if (current.isEmpty || latest.isEmpty) {
return latest;
}
final index = current.indexWhere((item) => identical(item, latest.first));
final first = latest.first;
final index = keyOf == null
? current.indexWhere((item) => identical(item, first))
: current.indexWhere((item) => keyOf(item) == keyOf(first));
if (index == 0) {
return latest;
}
+1
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/ui/ui.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
+19 -1
View File
@@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:ui';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/ui/ui.dart';
extension FutureExt<T> on Future<T> {
Future<T> withTimeout({
@@ -37,6 +37,24 @@ extension FutureExt<T> on Future<T> {
}
}
/// Resolves once, then hands out the value rather than the future that
/// resolved it: a completed future still runs its listeners in the zone it was
/// created in, which under test may be a fake-async zone nothing drives.
class ResolveOnce<T> {
final Future<T> Function() _resolve;
Future<T>? _pending;
({T value})? _resolved;
ResolveOnce(this._resolve);
Future<T> get value async {
if (_resolved case (:final value)) return value;
final value = await (_pending ??= _resolve());
_resolved = (value: value);
return value;
}
}
extension CompleterExt<T> on Completer<T> {
void safeCompleter(T value) {
if (isCompleted) {
+20
View File
@@ -5,6 +5,7 @@ import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class FlClashHttpOverrides extends HttpOverrides {
@@ -12,6 +13,25 @@ class FlClashHttpOverrides extends HttpOverrides {
FlClashHttpOverrides(this._container);
static const _coreRootsAsset =
'core/Clash.Meta/component/ca/ca-certificates.crt';
/// The bundle the core embeds; only release CI fills it.
static Future<void> trustCoreRoots() async {
try {
final data = await rootBundle.load(_coreRootsAsset);
if (data.lengthInBytes == 0) return;
SecurityContext.defaultContext.setTrustedCertificatesBytes(
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes),
);
} catch (e) {
commonPrint.log(
'trustCoreRoots error ${compactError(e)}',
logLevel: LogLevel.warning,
);
}
}
static String findProxyFor(ProviderContainer container, Uri url) {
return findProxyForReader(container.read, url);
}
+4
View File
@@ -17,6 +17,10 @@ abstract final class TextInputLimits {
static const portRange = 11;
static const number = 10;
static const interval = number;
/// mihomo multiplies these hours into a Duration, which overflows past
/// about 2.5 million and panics its update ticker.
static const geoUpdateInterval = 6;
static const search = 256;
static const rule = 1024;
static const filter = 1024;
-6
View File
@@ -1,6 +1,5 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'system.dart';
@@ -162,8 +161,3 @@ final class ShortcutLabels {
return parts(modifiers, key).join(isMacOS ? '' : '+');
}
}
SingleActivator controlSingleActivator(LogicalKeyboardKey trigger) {
final control = system.isMacOS ? false : true;
return SingleActivator(trigger, control: control, meta: !control);
}
+12 -13
View File
@@ -1,4 +1,5 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/ui/color.dart';
import 'package:material_ui/material_ui.dart';
import 'app_localizations.dart';
@@ -58,9 +59,9 @@ extension ProxyCardTypeL10n on ProxyCardType {
String get label {
final appLocalizations = currentAppLocalizations;
return switch (this) {
ProxyCardType.expand => appLocalizations.expand,
ProxyCardType.shrink => appLocalizations.shrink,
ProxyCardType.min => appLocalizations.min,
ProxyCardType.loose => appLocalizations.loose,
ProxyCardType.standard => appLocalizations.standard,
ProxyCardType.compact => appLocalizations.compact,
};
}
}
@@ -95,19 +96,14 @@ extension RestoreStrategyL10n on RestoreStrategy {
}
}
extension DynamicSchemeVariantL10n on DynamicSchemeVariant {
extension ColorStyleL10n on ColorStyle {
String get label {
final appLocalizations = currentAppLocalizations;
return switch (this) {
DynamicSchemeVariant.tonalSpot => appLocalizations.tonalSpotScheme,
DynamicSchemeVariant.fidelity => appLocalizations.fidelityScheme,
DynamicSchemeVariant.monochrome => appLocalizations.monochromeScheme,
DynamicSchemeVariant.neutral => appLocalizations.neutralScheme,
DynamicSchemeVariant.vibrant => appLocalizations.vibrantScheme,
DynamicSchemeVariant.expressive => appLocalizations.expressiveScheme,
DynamicSchemeVariant.content => appLocalizations.contentScheme,
DynamicSchemeVariant.rainbow => appLocalizations.rainbowScheme,
DynamicSchemeVariant.fruitSalad => appLocalizations.fruitSaladScheme,
ColorStyle.vivid => appLocalizations.vividStyle,
ColorStyle.tinted => appLocalizations.tintedStyle,
ColorStyle.soft => appLocalizations.softStyle,
ColorStyle.monochrome => appLocalizations.monochromeScheme,
};
}
}
@@ -117,7 +113,10 @@ extension LocaleL10n on Locale {
final appLocalizations = currentAppLocalizations;
return switch (toString()) {
'en' => appLocalizations.en,
'es' => appLocalizations.es,
'fr' => appLocalizations.fr,
'ja' => appLocalizations.ja,
'ko' => appLocalizations.ko,
'ru' => appLocalizations.ru,
'zh_CN' => appLocalizations.zhCN,
final code => code,
+8 -2
View File
@@ -2,9 +2,10 @@ import 'dart:math';
import 'dart:ui' show Size;
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/ui/ui.dart';
import 'package:flutter/widgets.dart' show BuildContext;
import 'constant.dart';
import 'num.dart';
double getWindowHeaderHeight({required bool isDesktop, required bool isMacOS}) {
if (!isDesktop) return 0;
@@ -69,7 +70,12 @@ int getProxiesColumns(double viewWidth, ProxiesLayout proxiesLayout) {
};
}
double get cardSpacing => 12.mAp;
double cardSpacingOf(BuildContext context, ViewMode viewMode) =>
switch (viewMode) {
ViewMode.mobile => 8.0,
ViewMode.laptop => 12.0,
ViewMode.desktop => 16.0,
}.mAp(context);
const profileItemMinWidth = 300.0;
const _profilesMaxColumns = 3;
-2
View File
@@ -7,8 +7,6 @@ import 'print.dart';
import 'protocol.dart';
import 'string.dart';
typedef InstallConfigCallBack = void Function(String url);
String? profileUrlFromQrCodes(Iterable<String?> values) {
for (final value in values) {
final text = value?.trim();
-199
View File
@@ -1,199 +0,0 @@
import 'dart:math';
import 'package:fl_clash/common/common.dart';
import 'package:material_ui/material_ui.dart';
typedef _TextMeasureKey = (
String?,
TextStyle?,
TextStyle?,
int?,
TextDirection?,
double?,
);
class Measure {
static const _textMeasureCacheLimit = 512;
final TextScaler _textScaler;
final BuildContext context;
final Map<String, dynamic> _measureMap;
final Map<_TextMeasureKey, Size> _textSizeCache = {};
final Map<_TextMeasureKey, bool> _textOverflowCache = {};
Measure.of(this.context, double textScaleFactor)
: _measureMap = {},
_textScaler = TextScaler.linear(textScaleFactor);
TextPainter _computeText(Text text, {TextStyle? style, double? maxWidth}) {
return TextPainter(
text: TextSpan(text: text.data, style: text.style ?? style),
maxLines: text.maxLines,
textScaler: _textScaler,
ellipsis: '...',
locale: Localizations.localeOf(context),
textDirection: text.textDirection ?? TextDirection.ltr,
)..layout(maxWidth: maxWidth ?? double.infinity);
}
R _measuring<R>(
Text text,
TextStyle? style,
double? maxWidth,
R Function(TextPainter painter) read,
) {
final textPainter = _computeText(text, style: style, maxWidth: maxWidth);
try {
return read(textPainter);
} finally {
textPainter.dispose();
}
}
_TextMeasureKey _measureKey(Text text, TextStyle? style, double? maxWidth) {
return (
text.data,
text.style,
style,
text.maxLines,
text.textDirection,
maxWidth,
);
}
R _cached<R extends Object>(
Map<_TextMeasureKey, R> cache,
_TextMeasureKey key,
R Function() compute,
) {
final cachedValue = cache[key];
if (cachedValue != null) {
return cachedValue;
}
if (cache.length >= _textMeasureCacheLimit) {
cache.clear();
}
return cache[key] = compute();
}
Size computeTextSize(Text text, {TextStyle? style, double? maxWidth}) {
return _cached(
_textSizeCache,
_measureKey(text, style, maxWidth),
() => _measuring(text, style, maxWidth, (painter) => painter.size),
);
}
bool computeTextIsOverflow(Text text, {TextStyle? style, double? maxWidth}) {
return _cached(
_textOverflowCache,
_measureKey(text, style, maxWidth),
() => _measuring(
text,
style,
maxWidth,
(painter) => painter.didExceedMaxLines,
),
);
}
double get bodyMediumHeight {
return _measureMap.updateCacheValue(
'bodyMediumHeight',
() => computeTextSize(
Text('X', style: context.textTheme.bodyMedium),
).height,
);
}
double get bodyLargeHeight {
return _measureMap.updateCacheValue(
'bodyLargeHeight',
() =>
computeTextSize(Text('X', style: context.textTheme.bodyLarge)).height,
);
}
double get listTitleHeight {
return _measureMap.updateCacheValue(
'listTitleHeight',
() => computeTextSize(Text('X', style: context.listTitleStyle)).height,
);
}
double get listSubtitleHeight {
return _measureMap.updateCacheValue(
'listSubtitleHeight',
() => computeTextSize(Text('X', style: context.listSubtitleStyle)).height,
);
}
double get sectionHeaderHeight {
return _measureMap.updateCacheValue(
'sectionHeaderHeight',
() =>
computeTextSize(Text('X', style: context.sectionHeaderStyle)).height,
);
}
double get listRowHeight =>
max(listRowMinHeight, listTitleHeight + 2 * listRowVerticalPadding);
double get listTwoLineRowHeight => max(
listRowMinHeight,
listTitleHeight + listSubtitleHeight + 2 * listRowVerticalPadding,
);
double get bodySmallHeight {
return _measureMap.updateCacheValue(
'bodySmallHeight',
() =>
computeTextSize(Text('X', style: context.textTheme.bodySmall)).height,
);
}
double get labelSmallHeight {
return _measureMap.updateCacheValue(
'labelSmallHeight',
() => computeTextSize(
Text('X', style: context.textTheme.labelSmall),
).height,
);
}
double get titleSmallHeight {
return _measureMap.updateCacheValue(
'titleSmallHeight',
() => computeTextSize(
Text('X', style: context.textTheme.titleSmall),
).height,
);
}
double get labelMediumHeight {
return _measureMap.updateCacheValue(
'labelMediumHeight',
() => computeTextSize(
Text('X', style: context.textTheme.labelMedium),
).height,
);
}
double get titleLargeHeight {
return _measureMap.updateCacheValue(
'titleLargeHeight',
() => computeTextSize(
Text('X', style: context.textTheme.titleLarge),
).height,
);
}
double get titleMediumHeight {
return _measureMap.updateCacheValue(
'titleMediumHeight',
() => computeTextSize(
Text('X', style: context.textTheme.titleMedium),
).height,
);
}
}
+64
View File
@@ -1,5 +1,8 @@
import 'package:fl_clash/common/constant.dart';
import 'package:fl_clash/database/database.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/ui/color.dart';
import 'preferences.dart';
import 'task.dart';
@@ -25,6 +28,10 @@ abstract interface class MigrationStore {
Future<void> clearClashConfig();
Future<void> setVersion(int version);
Future<int> getUpgradeVersion();
Future<void> setUpgradeVersion(int version);
}
class _AppMigrationStore implements MigrationStore {
@@ -63,6 +70,13 @@ class _AppMigrationStore implements MigrationStore {
@override
Future<void> setVersion(int version) => preferences.setVersion(version);
@override
Future<int> getUpgradeVersion() => preferences.getUpgradeVersion();
@override
Future<void> setUpgradeVersion(int version) =>
preferences.setUpgradeVersion(version);
}
class Migration {
@@ -75,8 +89,40 @@ class Migration {
static const currentVersion = 1;
/// One-off setting changes for upgrades, apart from [currentVersion], which
/// backups carry and an older app refuses when it is newer than its own.
static const upgradeVersion = 2;
static const Map<int, Config Function(Config config)> _upgrades = {
1: _resetThemeColors,
2: _keepGlassLook,
};
Future<Config> run() async {
final configMap = await _store.getConfigMap();
final config = await _migrate(configMap);
return _upgrade(config, installed: configMap != null);
}
Future<Config> _upgrade(Config config, {required bool installed}) async {
final from = await _store.getUpgradeVersion();
if (from >= upgradeVersion) {
return config;
}
var upgraded = config;
if (installed) {
for (var version = from + 1; version <= upgradeVersion; version++) {
upgraded = _upgrades[version]!(upgraded);
}
}
if (upgraded != config && !await _store.saveConfig(upgraded)) {
return config;
}
await _store.setUpgradeVersion(upgradeVersion);
return upgraded;
}
Future<Config> _migrate(Map<String, Object?>? configMap) async {
var oldVersion = await _store.getVersion();
Config? config;
if (oldVersion > currentVersion) {
@@ -145,6 +191,24 @@ class Migration {
}
}
Config _resetThemeColors(Config config) => config.copyWith(
themeProps: config.themeProps.copyWith(
primaryColor: defaultPrimaryColor,
primaryColors: defaultPrimaryColors,
colorStyle: ColorStyle.vivid,
),
);
// The standard glass became the ultra tier when a lighter standard joined it.
Config _keepGlassLook(Config config) =>
config.themeProps.liquidGlass == LiquidGlassMode.standard
? config.copyWith(
themeProps: config.themeProps.copyWith(
liquidGlass: LiquidGlassMode.ultra,
),
)
: config;
bool _isV0(Map<String, Object?>? configMap) =>
configMap?['proxiesStyle'] != null;
-443
View File
@@ -1,443 +0,0 @@
import 'dart:async';
import 'package:animations/animations.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/widgets/deferred_push.dart';
import 'package:fl_clash/widgets/drag_back.dart';
import 'package:fl_clash/widgets/keyboard_inset_hold.dart';
import 'package:fl_clash/widgets/page_transition.dart';
import 'package:fl_clash/widgets/sheet_navigator.dart';
import 'package:flutter/scheduler.dart';
import 'package:material_ui/material_ui.dart';
final rootNavigatorKey = GlobalKey<NavigatorState>();
class BaseNavigator {
static PageRoute<T> _routeOf<T>(BuildContext context, Widget child) {
if (!context.isMobileView) {
return CommonDesktopRoute(builder: (context) => child);
}
return CommonRoute(builder: (context) => child);
}
static Future<T?> push<T>(BuildContext context, Widget child) async {
return Navigator.of(context).push<T>(_routeOf(context, child));
}
static Future<T?> pushReplacement<T>(BuildContext context, Widget child) {
return Navigator.of(
context,
).pushReplacement<T, void>(_routeOf(context, child));
}
}
// Work a page starts on arrival drops frames while the route still animates.
// A sheet's first page arrives with the sheet, so the sheet's route counts too.
Iterable<ModalRoute<Object?>> _arrivalRoutes(ModalRoute<Object?>? page) sync* {
var route = page;
while (route != null) {
yield route;
final navigator = route.navigator;
if (navigator == null ||
!navigator.mounted ||
navigator.widget is! SheetPagesNavigator) {
return;
}
route = ModalRoute.of(navigator.context);
}
}
Future<void> whenRouteSettled(BuildContext context) async {
final routes = _arrivalRoutes(ModalRoute.of(context));
for (final route in routes) {
await _whenSettled(route);
}
}
bool isRouteArriving(BuildContext context) => _arrivalRoutes(
ModalRoute.of(context),
).any((route) => route.offstage || (route.animation?.isAnimating ?? false));
Future<void> _whenSettled(ModalRoute<Object?> route) async {
// HeroController builds a pushed route offstage for its first frame, with
// the animation pinned to completed, so it only tells the truth after that.
while (route.offstage && route.isActive) {
await SchedulerBinding.instance.endOfFrame;
}
final animation = route.animation;
if (animation == null || !animation.isAnimating) {
return;
}
final completer = Completer<void>();
void handleStatus(AnimationStatus status) {
if (status.isAnimating) {
return;
}
animation.removeStatusListener(handleStatus);
completer.complete();
}
animation.addStatusListener(handleStatus);
return completer.future;
}
const commonSharedXPageTransitions = SharedAxisPageTransitionsBuilder(
transitionType: SharedAxisTransitionType.horizontal,
fillColor: Colors.transparent,
);
class CommonDesktopRoute<T> extends PageRoute<T>
with DragBackRouteMixin<T>, DeferredPushRouteMixin<T> {
final Widget Function(BuildContext context) builder;
CommonDesktopRoute({required this.builder});
@override
Color? get barrierColor => null;
@override
String? get barrierLabel => null;
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return Semantics(
scopesRoute: true,
explicitChildNodes: true,
child: KeyboardInsetHold(child: builder(context)),
);
}
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return dragBackDetector(
isDragBackActive
? dragBackSlide(context, animation, child)
: FadeTransition(opacity: animation, child: child),
);
}
@override
bool get maintainState => true;
@override
Duration get transitionDuration => const Duration(milliseconds: 200);
@override
Duration get reverseTransitionDuration => const Duration(milliseconds: 200);
}
class CommonRoute<T> extends PageRoute<T>
with DragBackRouteMixin<T>, DeferredPushRouteMixin<T> {
final Widget Function(BuildContext context) builder;
CommonRoute({required this.builder});
bool _overSettledPage = false;
@override
void didChangePrevious(Route<dynamic>? previousRoute) {
super.didChangePrevious(previousRoute);
_overSettledPage =
previousRoute is CommonRoute &&
previousRoute.animation!.isCompleted &&
previousRoute.secondaryAnimation!.isDismissed;
}
@override
Color? get barrierColor => null;
@override
String? get barrierLabel => null;
@override
bool get maintainState => true;
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return Semantics(
scopesRoute: true,
explicitChildNodes: true,
child: KeyboardInsetHold(child: builder(context)),
);
}
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final dragging = isDragBackActive;
final surface = context.colorScheme.surface;
return dragBackDetector(
dragBackSlide(
context,
animation,
SharedXPageTransition(
animation: dragging ? kAlwaysCompleteAnimation : animation,
secondaryAnimation: dragging
? kAlwaysDismissedAnimation
: secondaryAnimation,
color: surface,
slide: SharedXPageTransition.pageSlide,
overSettledPage: _overSettledPage,
uncoveredByGesture: navigator!.userGestureInProgress,
child: ColoredBox(
color: dragging ? Colors.transparent : surface,
child: child,
),
),
),
);
}
@override
Duration get transitionDuration => SharedXPageTransition.duration;
}
final Animatable<Offset> _kRightMiddleTween = Tween<Offset>(
begin: const Offset(1.0, 0.0),
end: Offset.zero,
);
final Animatable<Offset> _kMiddleLeftTween = Tween<Offset>(
begin: Offset.zero,
end: const Offset(-1.0 / 3.0, 0.0),
);
class CommonPageTransitionsBuilder extends PageTransitionsBuilder {
const CommonPageTransitionsBuilder();
@override
Widget buildTransitions<T>(
PageRoute<T> route,
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return CommonPageTransition(
context: context,
primaryRouteAnimation: animation,
secondaryRouteAnimation: secondaryAnimation,
linearTransition: false,
child: child,
);
}
}
class CommonPageTransition extends StatefulWidget {
const CommonPageTransition({
super.key,
required this.context,
required this.primaryRouteAnimation,
required this.secondaryRouteAnimation,
required this.child,
required this.linearTransition,
});
final Widget child;
final Animation<double> primaryRouteAnimation;
final Animation<double> secondaryRouteAnimation;
final BuildContext context;
final bool linearTransition;
static Widget? delegatedTransition(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
bool allowSnapshotting,
Widget? child,
) {
final CurvedAnimation animation = CurvedAnimation(
parent: secondaryAnimation,
curve: Curves.linearToEaseOut,
reverseCurve: Curves.easeInToLinear,
);
final Animation<Offset> delegatedPositionAnimation = animation.drive(
_kMiddleLeftTween,
);
animation.dispose();
assert(debugCheckHasDirectionality(context));
final TextDirection textDirection = Directionality.of(context);
return SlideTransition(
position: delegatedPositionAnimation,
textDirection: textDirection,
transformHitTests: false,
child: child,
);
}
@override
State<CommonPageTransition> createState() => _CommonPageTransitionState();
}
class _CommonPageTransitionState extends State<CommonPageTransition> {
late Animation<Offset> _primaryPositionAnimation;
late Animation<Offset> _secondaryPositionAnimation;
late Animation<Decoration> _primaryShadowAnimation;
CurvedAnimation? _primaryPositionCurve;
CurvedAnimation? _secondaryPositionCurve;
CurvedAnimation? _primaryShadowCurve;
@override
void initState() {
super.initState();
_setupAnimation();
}
@override
void didUpdateWidget(covariant CommonPageTransition oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.primaryRouteAnimation != widget.primaryRouteAnimation ||
oldWidget.secondaryRouteAnimation != widget.secondaryRouteAnimation ||
oldWidget.linearTransition != widget.linearTransition) {
_disposeCurve();
_setupAnimation();
}
}
@override
void dispose() {
_disposeCurve();
super.dispose();
}
void _disposeCurve() {
_primaryPositionCurve?.dispose();
_secondaryPositionCurve?.dispose();
_primaryShadowCurve?.dispose();
_primaryPositionCurve = null;
_secondaryPositionCurve = null;
_primaryShadowCurve = null;
}
void _setupAnimation() {
if (!widget.linearTransition) {
_primaryPositionCurve = CurvedAnimation(
parent: widget.primaryRouteAnimation,
curve: Curves.fastEaseInToSlowEaseOut,
reverseCurve: Curves.fastEaseInToSlowEaseOut.flipped,
);
_secondaryPositionCurve = CurvedAnimation(
parent: widget.secondaryRouteAnimation,
curve: Curves.linearToEaseOut,
reverseCurve: Curves.easeInToLinear,
);
_primaryShadowCurve = CurvedAnimation(
parent: widget.primaryRouteAnimation,
curve: Curves.linearToEaseOut,
);
}
_primaryPositionAnimation =
(_primaryPositionCurve ?? widget.primaryRouteAnimation).drive(
_kRightMiddleTween,
);
_secondaryPositionAnimation =
(_secondaryPositionCurve ?? widget.secondaryRouteAnimation).drive(
_kMiddleLeftTween,
);
_primaryShadowAnimation =
(_primaryShadowCurve ?? widget.primaryRouteAnimation).drive(
DecorationTween(
begin: const _CommonEdgeShadowDecoration(),
end: const _CommonEdgeShadowDecoration(<Color>[
Color(0x04000000),
Colors.transparent,
]),
),
);
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasDirectionality(context));
final TextDirection textDirection = Directionality.of(context);
return SlideTransition(
position: _secondaryPositionAnimation,
textDirection: textDirection,
transformHitTests: false,
child: SlideTransition(
position: _primaryPositionAnimation,
textDirection: textDirection,
child: DecoratedBoxTransition(
decoration: _primaryShadowAnimation,
child: widget.child,
),
),
);
}
}
class _CommonEdgeShadowDecoration extends Decoration {
final List<Color>? _colors;
const _CommonEdgeShadowDecoration([this._colors]);
@override
BoxPainter createBoxPainter([VoidCallback? onChanged]) {
return _CommonEdgeShadowPainter(this, onChanged);
}
}
class _CommonEdgeShadowPainter extends BoxPainter {
_CommonEdgeShadowPainter(this._decoration, super.onChanged)
: assert(_decoration._colors == null || _decoration._colors.length > 1);
final _CommonEdgeShadowDecoration _decoration;
@override
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
final List<Color>? colors = _decoration._colors;
if (colors == null) {
return;
}
final double shadowWidth = 0.05 * configuration.size!.width;
final double shadowHeight = configuration.size!.height;
final double bandWidth = shadowWidth / (colors.length - 1);
final TextDirection? textDirection = configuration.textDirection;
assert(textDirection != null);
final (double shadowDirection, double start) = switch (textDirection!) {
TextDirection.rtl => (1, offset.dx + configuration.size!.width),
TextDirection.ltr => (-1, offset.dx),
};
int bandColorIndex = 0;
for (int dx = 0; dx < shadowWidth; dx += 1) {
if (dx ~/ bandWidth != bandColorIndex) {
bandColorIndex += 1;
}
final Paint paint = Paint()
..color = Color.lerp(
colors[bandColorIndex],
colors[bandColorIndex + 1],
(dx % bandWidth) / bandWidth,
)!;
final double x = start + shadowDirection * dx;
canvas.drawRect(
Rect.fromLTWH(x - 1.0, offset.dy, 1.0, shadowHeight),
paint,
);
}
}
}
-11
View File
@@ -1,8 +1,5 @@
import 'dart:math';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/common.dart';
import 'package:fl_clash/state.dart';
import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart' show NumberFormat;
import 'package:material_ui/material_ui.dart';
@@ -24,14 +21,6 @@ extension NumExt on num {
return formatted;
}
double get ap {
return this * (1 + (globalState.theme.textScaleFactor - 1) * 0.5);
}
double get mAp {
return this * min((1 + (globalState.theme.textScaleFactor - 1) * 0.5), 1);
}
TrafficShow get traffic {
const units = TrafficUnit.values;
var size = toDouble();
+24 -20
View File
@@ -8,11 +8,10 @@ import 'package:path_provider/path_provider.dart';
class AppPath {
static AppPath? _instance;
Completer<Directory> dataDir = Completer();
late final Future<Directory?> _downloadDir = downloadDirectory();
Completer<Directory> tempDir = Completer();
late ResolveOnce<Directory> _dataDir;
late ResolveOnce<Directory?> _downloadDir;
late ResolveOnce<Directory> _tempDir;
Completer<Directory> cacheDir = Completer();
late String appDirPath;
@visibleForTesting
static Future<Directory> Function() supportDirectory =
@@ -31,16 +30,21 @@ class AppPath {
getDownloadsDirectory;
AppPath._internal() {
appDirPath = join(dirname(Platform.resolvedExecutable));
supportDirectory().then((value) {
dataDir.complete(value);
});
temporaryDirectory().then((value) {
tempDir.complete(value);
});
cacheDirectory().then((value) {
cacheDir.complete(value);
});
_bind();
}
@visibleForTesting
Future<void> rebind() async {
cacheDir = Completer();
_bind();
await Future.wait([_dataDir.value, _downloadDir.value, _tempDir.value]);
}
void _bind() {
_dataDir = ResolveOnce(supportDirectory);
_downloadDir = ResolveOnce(downloadDirectory);
_tempDir = ResolveOnce(temporaryDirectory);
cacheDirectory().then(cacheDir.complete);
}
factory AppPath() {
@@ -66,12 +70,12 @@ class AppPath {
}
Future<String> get downloadDirPath async {
final directory = await _downloadDir;
final directory = await _downloadDir.value;
return directory?.path ?? await homeDirPath;
}
Future<String> get homeDirPath async {
final directory = await dataDir.future;
final directory = await _dataDir.value;
return directory.path;
}
@@ -81,7 +85,7 @@ class AppPath {
}
Future<String> get tempFilePath async {
final mTempDir = await tempDir.future;
final mTempDir = await _tempDir.value;
return join(mTempDir.path, 'temp$uniqueId');
}
@@ -96,12 +100,12 @@ class AppPath {
}
Future<String> get sharedPreferencesPath async {
final directory = await dataDir.future;
final directory = await _dataDir.value;
return join(directory.path, 'shared_preferences.json');
}
Future<String> get profilesPath async {
final directory = await dataDir.future;
final directory = await _dataDir.value;
return join(directory.path, profilesDirectoryName);
}
@@ -156,7 +160,7 @@ class AppPath {
}
Future<String> get tempPath async {
final directory = await tempDir.future;
final directory = await _tempDir.value;
return directory.path;
}
}
+40 -20
View File
@@ -6,19 +6,29 @@ import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/system_dns.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class Preferences {
static Preferences? _instance;
Completer<SharedPreferences?> sharedPreferencesCompleter = Completer();
var _store = ResolveOnce(_open);
Future<bool> get isInit async =>
await sharedPreferencesCompleter.future != null;
Future<bool> get isInit async => await _store.value != null;
Preferences._internal() {
SharedPreferences.getInstance()
.then((value) => sharedPreferencesCompleter.complete(value))
.onError((_, _) => sharedPreferencesCompleter.complete(null));
Preferences._internal();
static Future<SharedPreferences?> _open() async {
try {
return await SharedPreferences.getInstance();
} catch (_) {
return null;
}
}
@visibleForTesting
Future<void> rebind() async {
_store = ResolveOnce(_open);
await _store.value;
}
factory Preferences() {
@@ -27,23 +37,33 @@ class Preferences {
}
Future<int> getVersion() async {
final preferences = await sharedPreferencesCompleter.future;
final preferences = await _store.value;
return preferences?.getInt('version') ?? 0;
}
Future<void> setVersion(int version) async {
final preferences = await sharedPreferencesCompleter.future;
final preferences = await _store.value;
await preferences?.setInt('version', version);
}
Future<int> getUpgradeVersion() async {
final preferences = await _store.value;
return preferences?.getInt('upgradeVersion') ?? 0;
}
Future<void> setUpgradeVersion(int version) async {
final preferences = await _store.value;
await preferences?.setInt('upgradeVersion', version);
}
Future<void> saveShareState(SharedState shareState) async {
final preferences = await sharedPreferencesCompleter.future;
final preferences = await _store.value;
await preferences?.setString('sharedState', json.encode(shareState));
}
Future<Map<String, Object?>?> getConfigMap() async {
try {
final preferences = await sharedPreferencesCompleter.future;
final preferences = await _store.value;
final configString = preferences?.getString(configKey);
if (configString == null) return null;
final Map<String, Object?>? configMap = json.decode(configString);
@@ -59,7 +79,7 @@ class Preferences {
Future<Map<String, Object?>?> getClashConfigMap() async {
try {
final preferences = await sharedPreferencesCompleter.future;
final preferences = await _store.value;
final clashConfigString = preferences?.getString(clashConfigKey);
if (clashConfigString == null) return null;
return json.decode(clashConfigString);
@@ -74,7 +94,7 @@ class Preferences {
Future<void> clearClashConfig() async {
try {
final preferences = await sharedPreferencesCompleter.future;
final preferences = await _store.value;
await preferences?.remove(clashConfigKey);
return;
} catch (e) {
@@ -95,13 +115,13 @@ class Preferences {
}
Future<bool> saveConfig(Config config) async {
final preferences = await sharedPreferencesCompleter.future;
final preferences = await _store.value;
return preferences?.setString(configKey, json.encode(config)) ?? false;
}
Future<SystemDnsRecord?> getSystemDnsRecord() async {
try {
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
final sharedPreferencesIns = await _store.value;
final raw = sharedPreferencesIns?.getString(systemDnsRecordKey);
if (raw == null) {
return null;
@@ -117,7 +137,7 @@ class Preferences {
}
Future<void> saveSystemDnsRecord(SystemDnsRecord record) async {
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
final sharedPreferencesIns = await _store.value;
await sharedPreferencesIns?.setString(
systemDnsRecordKey,
json.encode(record),
@@ -125,13 +145,13 @@ class Preferences {
}
Future<void> clearSystemDnsRecord() async {
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
final sharedPreferencesIns = await _store.value;
await sharedPreferencesIns?.remove(systemDnsRecordKey);
}
Future<BootRecord?> getBootRecord() async {
try {
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
final sharedPreferencesIns = await _store.value;
final raw = sharedPreferencesIns?.getString(bootRecordKey);
if (raw == null) {
return null;
@@ -147,12 +167,12 @@ class Preferences {
}
Future<void> saveBootRecord(BootRecord record) async {
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
final sharedPreferencesIns = await _store.value;
await sharedPreferencesIns?.setString(bootRecordKey, json.encode(record));
}
Future<void> clearPreferences() async {
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
final sharedPreferencesIns = await _store.value;
await sharedPreferencesIns?.clear();
}
}
+48
View File
@@ -1,9 +1,57 @@
import 'package:fl_clash/common/constant.dart';
import 'package:fl_clash/common/print.dart';
import 'package:fl_clash/common/system.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart' show ProxyState;
import 'package:proxy/proxy.dart';
final proxy = system.isDesktop && !safeModeBuild ? Proxy() : null;
final systemProxyQueue = proxy == null ? null : SystemProxyQueue(proxy!);
/// Exit's stop goes through the same queue as every other change, so a start
/// still waiting behind a slow call cannot turn the proxy back on after it.
class SystemProxyQueue {
final Proxy _proxy;
Future<void> _tail = Future.value();
bool _closed = false;
SystemProxyQueue(this._proxy);
Future<void> apply(ProxyState state) {
return _enqueue(() async {
if (_closed) {
return;
}
final applied = state.isStart && state.systemProxy
? await _proxy.startProxy(state.port, state.bassDomain)
: await _proxy.stopProxy();
if (!applied) {
commonPrint.log(
'update system proxy failed',
logLevel: LogLevel.warning,
);
}
});
}
Future<void> close() {
_closed = true;
return _enqueue(() async {
await _proxy.stopProxy();
});
}
Future<void> _enqueue(Future<void> Function() task) {
return _tail = _tail.then((_) => task()).catchError((Object error) {
commonPrint.log(
'update system proxy failed: $error',
logLevel: LogLevel.warning,
);
});
}
}
String proxyEnvCommand(int port, {required bool isWindows}) {
final url = 'http://127.0.0.1:$port';
return isWindows ? 'set \$env:all_proxy=$url' : 'export all_proxy=$url';
-399
View File
@@ -1,399 +0,0 @@
import 'dart:async';
import 'dart:math';
import 'dart:ui';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/widgets/scroll.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:material_ui/material_ui.dart';
bool isTouchPlatform(TargetPlatform platform) => switch (platform) {
TargetPlatform.android ||
TargetPlatform.iOS ||
TargetPlatform.fuchsia => true,
TargetPlatform.linux ||
TargetPlatform.macOS ||
TargetPlatform.windows => false,
};
class BaseScrollBehavior extends MaterialScrollBehavior {
const BaseScrollBehavior({this.scrollbarPadding = EdgeInsets.zero});
final EdgeInsets scrollbarPadding;
@override
Set<PointerDeviceKind> get dragDevices => {
PointerDeviceKind.touch,
PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus,
PointerDeviceKind.trackpad,
if (system.isDesktop) PointerDeviceKind.mouse,
PointerDeviceKind.unknown,
};
bool showScrollbar(BuildContext context) =>
!isTouchPlatform(getPlatform(context));
@override
Widget buildScrollbar(
BuildContext context,
Widget child,
ScrollableDetails details,
) {
if (axisDirectionToAxis(details.direction) == Axis.horizontal ||
!showScrollbar(context)) {
return child;
}
return CommonScrollBar.ambient(
controller: details.controller,
padding: scrollbarPadding,
child: child,
);
}
@override
bool shouldNotify(covariant BaseScrollBehavior oldDelegate) {
return oldDelegate.scrollbarPadding != scrollbarPadding;
}
}
class HiddenBarScrollBehavior extends BaseScrollBehavior {
const HiddenBarScrollBehavior();
@override
bool showScrollbar(BuildContext context) => false;
}
class ShowBarScrollBehavior extends BaseScrollBehavior {
const ShowBarScrollBehavior({super.scrollbarPadding});
@override
bool showScrollbar(BuildContext context) => true;
}
class NextClampingScrollPhysics extends ClampingScrollPhysics {
const NextClampingScrollPhysics({super.parent});
@override
NextClampingScrollPhysics applyTo(ScrollPhysics? ancestor) {
return NextClampingScrollPhysics(parent: buildParent(ancestor));
}
// iOS momentum is sized for its friction fling; a clamping one multiplies it.
@override
double carriedMomentum(double existingVelocity) => 0;
@override
Simulation? createBallisticSimulation(
ScrollMetrics position,
double velocity,
) {
final Tolerance tolerance = toleranceFor(position);
if (position.outOfRange) {
double? end;
if (position.pixels > position.maxScrollExtent) {
end = position.maxScrollExtent;
}
if (position.pixels < position.minScrollExtent) {
end = position.minScrollExtent;
}
assert(end != null);
return ScrollSpringSimulation(
spring,
end!,
end,
min(0.0, velocity),
tolerance: tolerance,
);
}
if (velocity.abs() < tolerance.velocity) {
return null;
}
if (velocity > 0.0 && position.pixels >= position.maxScrollExtent) {
return null;
}
if (velocity < 0.0 && position.pixels <= position.minScrollExtent) {
return null;
}
return ClampingScrollSimulation(
position: position.pixels,
velocity: velocity,
tolerance: tolerance,
);
}
}
class FollowEndScrollPhysics extends NextClampingScrollPhysics {
const FollowEndScrollPhysics({required this.isFollowing, super.parent});
final ValueGetter<bool> isFollowing;
static bool isAtEnd(ScrollMetrics metrics) =>
metrics.pixels >= metrics.maxScrollExtent - precisionErrorTolerance;
@override
FollowEndScrollPhysics applyTo(ScrollPhysics? ancestor) {
return FollowEndScrollPhysics(
isFollowing: isFollowing,
parent: buildParent(ancestor),
);
}
@override
double adjustPositionForNewDimensions({
required ScrollMetrics oldPosition,
required ScrollMetrics newPosition,
required bool isScrolling,
required double velocity,
}) {
if (isFollowing() && isAtEnd(oldPosition)) {
return newPosition.maxScrollExtent;
}
return super.adjustPositionForNewDimensions(
oldPosition: oldPosition,
newPosition: newPosition,
isScrolling: isScrolling,
velocity: velocity,
);
}
}
double zeroDividerExtentEstimation(int? index, double crossAxisExtent) {
return index != null && index.isEven ? 100 : 0;
}
mixin ScrubbableScrollPosition on ScrollPosition {
bool isScrubbing = false;
}
/// Drags move the sheet until it opens fully, so a scrollbar hides till then.
mixin SheetScrollController on ScrollController {
ValueListenable<bool> get sheetOpen;
}
const _stopEpsilon = 1e-3;
/// Settles each scroll on a whole row of [RowSnapScrollController.itemExtent].
class RowSnapScrollPhysics extends ScrollPhysics {
const RowSnapScrollPhysics({super.parent});
@override
RowSnapScrollPhysics applyTo(ScrollPhysics? ancestor) {
return RowSnapScrollPhysics(parent: buildParent(ancestor));
}
bool _canSnap(_RowSnapScrollPosition position) =>
position.itemExtent > 0 &&
position.maxScrollExtent > position.minScrollExtent;
// The end is rarely row-aligned, so it replaces the nearest row as a stop.
int _lastStop(_RowSnapScrollPosition position) => max(
((position.maxScrollExtent - position.minScrollExtent) /
position.itemExtent)
.round(),
1,
);
double _stopAt(_RowSnapScrollPosition position, double pixels) {
final lastRow = (_lastStop(position) - 1) * position.itemExtent;
final offset = pixels - position.minScrollExtent;
if (offset <= lastRow) {
return offset / position.itemExtent;
}
final range = position.maxScrollExtent - position.minScrollExtent;
return _lastStop(position) - 1 + (offset - lastRow) / (range - lastRow);
}
double _pixelsOf(_RowSnapScrollPosition position, int stop) {
if (stop >= _lastStop(position)) {
return position.maxScrollExtent;
}
return position.minScrollExtent + max(stop, 0) * position.itemExtent;
}
Simulation? _springToStop(
_RowSnapScrollPosition position,
double velocity,
ScrollDirection direction,
) {
final stop = _stopAt(position, position.pixels);
final target = _pixelsOf(position, switch (direction) {
ScrollDirection.reverse => (stop - _stopEpsilon).ceil(),
ScrollDirection.forward => (stop + _stopEpsilon).floor(),
ScrollDirection.idle => stop.round(),
});
if (target == position.pixels) {
return null;
}
return ScrollSpringSimulation(
spring,
position.pixels,
target,
velocity,
tolerance: toleranceFor(position),
);
}
Simulation? _settleWheel(
_RowSnapScrollPosition position,
ScrollDirection direction,
) {
return _canSnap(position) ? _springToStop(position, 0, direction) : null;
}
@override
Simulation? createBallisticSimulation(
ScrollMetrics position,
double velocity,
) {
if (position is! _RowSnapScrollPosition ||
!_canSnap(position) ||
position.outOfRange ||
velocity.abs() >= toleranceFor(position).velocity) {
return super.createBallisticSimulation(position, velocity);
}
return _springToStop(position, velocity, ScrollDirection.idle);
}
}
class RowSnapScrollController extends ScrollController {
RowSnapScrollController({
super.initialScrollOffset,
super.keepScrollOffset,
super.debugLabel,
});
double itemExtent = 0;
@override
ScrollPosition createScrollPosition(
ScrollPhysics physics,
ScrollContext context,
ScrollPosition? oldPosition,
) {
return _RowSnapScrollPosition(
controller: this,
physics: physics,
context: context,
initialPixels: initialScrollOffset,
keepScrollOffset: keepScrollOffset,
oldPosition: oldPosition,
debugLabel: debugLabel,
);
}
}
const _wheelSettleDelay = Duration(milliseconds: 200);
// Stock wheel handling settles after every tick instead of once it rests.
class _RowSnapScrollPosition extends ScrollPositionWithSingleContext {
_RowSnapScrollPosition({
required this.controller,
required super.physics,
required super.context,
super.initialPixels,
super.keepScrollOffset,
super.oldPosition,
super.debugLabel,
});
final RowSnapScrollController controller;
double get itemExtent => controller.itemExtent;
Timer? _wheelSettle;
bool _wheeling = false;
@override
void pointerScroll(double delta) {
_wheeling = true;
try {
super.pointerScroll(delta);
} finally {
_wheeling = false;
}
}
@override
void goBallistic(double velocity) {
final physics = this.physics;
if (!_wheeling || physics is! RowSnapScrollPhysics) {
super.goBallistic(velocity);
return;
}
final direction = userScrollDirection;
goIdle();
_wheelSettle = Timer(_wheelSettleDelay, () {
final simulation = physics._settleWheel(this, direction);
if (simulation != null) {
beginActivity(
BallisticScrollActivity(
this,
simulation,
context.vsync,
shouldIgnorePointer,
),
);
}
});
}
@override
void beginActivity(ScrollActivity? newActivity) {
_wheelSettle?.cancel();
super.beginActivity(newActivity);
}
@override
void dispose() {
_wheelSettle?.cancel();
super.dispose();
}
}
class ReverseScrollController extends ScrollController {
ReverseScrollController({
super.initialScrollOffset,
super.keepScrollOffset,
super.debugLabel,
});
@override
ScrollPosition createScrollPosition(
ScrollPhysics physics,
ScrollContext context,
ScrollPosition? oldPosition,
) {
return ReverseScrollPosition(
physics: physics,
context: context,
initialPixels: initialScrollOffset,
keepScrollOffset: keepScrollOffset,
oldPosition: oldPosition,
debugLabel: debugLabel,
);
}
}
class ReverseScrollPosition extends ScrollPositionWithSingleContext {
ReverseScrollPosition({
required super.physics,
required super.context,
super.initialPixels = 0.0,
super.keepScrollOffset,
super.oldPosition,
super.debugLabel,
});
bool _isInit = false;
@override
bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) {
if (!_isInit) {
correctPixels(maxScrollExtent);
_isInit = true;
}
return super.applyContentDimensions(minScrollExtent, maxScrollExtent);
}
}
-5
View File
@@ -60,11 +60,6 @@ class System {
};
}
Future<bool> didCrashOnPreviousExecution() async {
if (!isAndroid) return false;
return await app?.didCrashOnPreviousExecution() ?? false;
}
Future<AppExitInfo?> lastExitInfo() async {
if (!isAndroid) return null;
return app?.getLastExitInfo();
+50 -63
View File
@@ -17,14 +17,6 @@ Future<T> _decodeJSON<T>(String content) async {
return json.decode(content);
}
Future<String> encodeJSONTask<T>(T data) async {
return compute<T, String>(_encodeJSON, data);
}
Future<String> _encodeJSON<T>(T content) async {
return json.encode(content);
}
Future<String> encodeYamlTask<T>(T data) async {
return compute<T, String>(_encodeYaml, data);
}
@@ -33,14 +25,6 @@ Future<String> _encodeYaml<T>(T content) async {
return yaml.encode(content);
}
Future<String> encodeMD5Task(String data) async {
return compute<String, String>(_encodeMD5, data);
}
Future<String> _encodeMD5<T>(String content) async {
return content.toMd5();
}
/// Throws [FormatException] when the file is not UTF-8.
Future<String?> readTextFileTask(String path) {
return compute(_readTextFile, path);
@@ -90,34 +74,45 @@ Future<List<Group>> buildGroups(ComputeGroupsState state) async {
}
Future<ClashConfig> clashConfigTask(Map<String, dynamic> data) async {
return compute<Map<String, dynamic>, ClashConfig>(buildClashConfig, data);
return compute<Map<String, dynamic>, ClashConfig>(_buildClashConfig, data);
}
@visibleForTesting
ClashConfig buildClashConfig(Map<String, dynamic> configMap) {
final clashConfig = ClashConfig.fromJson(configMap);
final proxyTypeMap = <String, String>{};
for (final proxy in clashConfig.proxies) {
proxyTypeMap[proxy.name] = proxy.type;
}
for (final proxyGroup in clashConfig.proxyGroups) {
proxyTypeMap[proxyGroup.name] = proxyGroup.type.value;
}
return clashConfig.copyWith(proxyTypeMap: proxyTypeMap);
ClashConfig _buildClashConfig(Map<String, dynamic> configMap) {
return ClashConfig.fromJson(configMap);
}
Future<({String yaml, String md5})> makeRealProfileTask(
MakeRealProfileState data,
) async {
return compute<MakeRealProfileState, ({String yaml, String md5})>(
_makeRealProfileTask,
data,
typedef ConfigCounts = ({int groups, int proxies, int rules});
typedef RealProfile = ({String yaml, String md5, ConfigCounts counts});
/// Counts what the Core was handed, not what the profile file declares.
ConfigCounts configCountsOf(Map<Object?, Object?> config) {
int lengthOf(Object? value) => value is List ? value.length : 0;
return (
groups: lengthOf(config['proxy-groups']),
proxies: lengthOf(config['proxies']),
rules: lengthOf(config['rules']),
);
}
Future<({String yaml, String md5})> _makeRealProfileTask(
MakeRealProfileState data,
) async {
String? lastMatchTarget(List<String> rules) {
for (int i = rules.length - 1; i >= 0; i--) {
final parsed = Rule.parse(rules[i]);
if (parsed.ruleAction == RuleAction.MATCH) {
final target = parsed.ruleTarget;
if (target != null && target.isNotEmpty) {
return target;
}
}
}
return null;
}
Future<RealProfile> makeRealProfileTask(MakeRealProfileState data) async {
return compute<MakeRealProfileState, RealProfile>(_makeRealProfileTask, data);
}
Future<RealProfile> _makeRealProfileTask(MakeRealProfileState data) async {
final rawConfig = Map.from(data.rawConfig);
final realPatchConfig = data.realPatchConfig;
final profilesPath = data.profilesPath;
@@ -212,11 +207,25 @@ Future<({String yaml, String md5})> _makeRealProfileTask(
rawConfig['authentication'] = data.authentication;
rawConfig['skip-auth-prefixes'] = [];
rawConfig['mode'] = realPatchConfig.mode.name;
rawConfig['tun'] = {
// auto-route is always on, so without auto-detect-interface the core sends
// its own outbound back into the TUN unless interface-name binds it, and
// interface-name wins over the detected interface anyway. A profile's own
// dns-hijack may leave queries to other resolvers unhijacked, so only a
// picked key replaces any:53.
final tun = {
if (rawConfig['tun'] is Map) ...rawConfig['tun'] as Map,
'dns-hijack': defaultDnsHijack,
...overrides.tun.overrideJson(overrides.tunOverrideKeys),
...realPatchConfig.tun.toJson(),
...realPatchConfig.tun.coreJson,
'auto-detect-interface': true,
};
if (data.bypassPrivateRoute) {
tun['route-exclude-address'] = [
if (tun['route-exclude-address'] case final List routes) ...routes,
...privateRouteAddress,
];
}
rawConfig['tun'] = tun;
rawConfig['geodata-loader'] = realPatchConfig.geodataLoader.name;
rawConfig['geo-auto-update'] = realPatchConfig.geoAutoUpdate;
rawConfig['geo-update-interval'] = realPatchConfig.geoUpdateInterval;
@@ -334,16 +343,7 @@ Future<({String yaml, String md5})> _makeRealProfileTask(
}
if (hasMatchPlaceholder && replacementTarget == null) {
for (int i = rules.length - 1; i >= 0; i--) {
final parsed = Rule.parse(rules[i]);
if (parsed.ruleAction == RuleAction.MATCH) {
final target = parsed.ruleTarget;
if (target != null && target.isNotEmpty) {
replacementTarget = target;
break;
}
}
}
replacementTarget = lastMatchTarget(rules);
}
final List<String> finalAddedRules;
@@ -374,7 +374,7 @@ Future<({String yaml, String md5})> _makeRealProfileTask(
}
rawConfig['rules'] = rules;
final yaml = await _encodeYaml(Map<String, dynamic>.from(rawConfig));
return (yaml: yaml, md5: yaml.toMd5());
return (yaml: yaml, md5: yaml.toMd5(), counts: configCountsOf(rawConfig));
}
typedef ShakingStoreArgs = ({
@@ -621,16 +621,3 @@ Future<MigrationData> migrateLegacyConfig({
disabledRules: disabledRules,
);
}
Future<List<T>> mapListTask<T, S>(List<S> results, T Function(S) mapper) async {
return compute<({List<S> results, T Function(S) mapper}), List<T>>(
_mapListTask,
(results: results, mapper: mapper),
);
}
Future<List<T>> _mapListTask<T, S>(
({List<S> results, T Function(S) mapper}) args,
) async {
return args.results.map((item) => args.mapper(item)).toList();
}
-49
View File
@@ -1,49 +0,0 @@
import 'package:fl_clash/common/common.dart';
import 'package:material_ui/material_ui.dart';
class CommonTheme {
final BuildContext context;
final Map<String, Color> _colorMap;
final double textScaleFactor;
CommonTheme.of(this.context, this.textScaleFactor) : _colorMap = {};
Color get darkenSecondaryContainer {
return _colorMap.updateCacheValue(
'darkenSecondaryContainer',
() => context.colorScheme.secondaryContainer.blendDarken(
context,
factor: 0.1,
),
);
}
Color get darkenSecondaryContainerLighter {
return _colorMap.updateCacheValue(
'darkenSecondaryContainerLighter',
() => context.colorScheme.secondaryContainer
.blendDarken(context, factor: 0.1)
.opacity60,
);
}
Color get darken2SecondaryContainer {
return _colorMap.updateCacheValue(
'darken2SecondaryContainer',
() => context.colorScheme.secondaryContainer.blendDarken(
context,
factor: 0.2,
),
);
}
Color get darken3PrimaryContainer {
return _colorMap.updateCacheValue(
'darken3PrimaryContainer',
() => context.colorScheme.primaryContainer.blendDarken(
context,
factor: 0.3,
),
);
}
}
+6 -1
View File
@@ -67,6 +67,11 @@ class AppTray implements TrayPort {
@override
Future<void> shutdown() async {
_isShutDown = true;
// Removing a status item makes macOS forget where the user dragged it in
// the menu bar; one that goes away with the process keeps its place.
if (isMacOS) {
return;
}
await Tray.instance.hide();
}
@@ -83,7 +88,7 @@ class AppTray implements TrayPort {
TraySpec(
icon: TrayIcon.asset(
getTrayIcon(
isStart: trayState.isStart,
isStart: trayState.isStart && !trayState.suspended,
tunEnable: trayState.tunEnable,
safeMode: trayState.safeMode,
),
+18 -1
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
@@ -50,12 +51,21 @@ class Window implements WindowPort {
if (!system.isMacOS || version > 10) {
await desktopWindow.setTitleBarStyle(TitleBarStyle.hidden);
}
await desktopWindow.setSize(props.size);
await desktopWindow.setSize(props.savedSize ?? await _defaultSize());
await desktopWindow.setMinimumSize(const Size(380, 400));
await _windowPosition(props);
await desktopWindow.setPreventClose(true);
}
Future<Size> _defaultSize() async {
try {
final display = await screenRetriever.getPrimaryDisplay();
return fittedWindowSize(display.visibleSize ?? display.size);
} catch (_) {
return defaultWindowSize;
}
}
Future<void> _windowPosition(WindowProps props) async {
if (!_supportsPosition) {
return;
@@ -203,6 +213,13 @@ class Window implements WindowPort {
}
}
/// Keeps a margin of the work area around a first window on a small screen.
@visibleForTesting
Size fittedWindowSize(Size visible) => Size(
math.min(defaultWindowSize.width, visible.width * 0.9),
math.min(defaultWindowSize.height, visible.height * 0.9),
);
/// On Windows the plugin scales by the window's monitor and screen_retriever by
/// each display's own, so only physical pixels compare across mixed scales.
@visibleForTesting
+18 -4
View File
@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/core.dart';
import 'package:fl_clash/core/interface.dart';
import 'package:fl_clash/core/spill.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter/foundation.dart' show visibleForTesting;
@@ -290,10 +291,6 @@ class CoreController {
return _readConfig(await appPath.getProfilePath(id.toString()));
}
Future<Map<String, dynamic>> getAppliedConfig() async {
return _readConfig(await appPath.configFilePath);
}
Future<Map<String, dynamic>> _readConfig(String path) async {
final data = Map<String, dynamic>.from(await _interface.getConfig(path));
data['rules'] = data['rule'];
@@ -301,6 +298,23 @@ class CoreController {
return data;
}
Future<Map<String, dynamic>> getProfileOutbounds(int id) async {
return _interface.getProfileOutbounds(
await appPath.getProfilePath(id.toString()),
);
}
Future<String> buildProfile(SetupBase base) async {
return readSpilledText(await _interface.buildProfile(base));
}
Future<({ProfileBase base, String path})> getProfileBase(int id) async {
final path = await appPath.getProfilePath(id.toString());
final base = await _interface.getProfileBase(path);
final config = Map<String, dynamic>.from(base.config)..remove('rule');
return (base: base.copyWith(config: config), path: path);
}
Future<Traffic> getTraffic(bool onlyStatisticsProxy) async {
return _interface.getTraffic(onlyStatisticsProxy);
}
+28 -6
View File
@@ -5,6 +5,7 @@ import 'dart:typed_data';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/event.dart';
import 'package:fl_clash/core/method.dart';
import 'package:fl_clash/core/spill.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:flutter/foundation.dart' show visibleForTesting;
@@ -34,7 +35,8 @@ abstract interface class CoreRpcChannel {
final class CoreRpcClient implements CoreRpcChannel {
final DesktopCoreTransport transport;
final Duration extensionGrace;
final Map<String, Completer<Object?>> _pending = {};
final Map<String, ({Completer<Object?> completer, CoreMethod method})>
_pending = {};
late final StreamSubscription<Uint8List> _frameSubscription;
late final StreamSubscription<DesktopTransportEvent> _eventSubscription;
@@ -75,7 +77,7 @@ final class CoreRpcClient implements CoreRpcChannel {
}
final id = '${++_methodCallId}';
final completer = Completer<Object?>();
_pending[id] = completer;
_pending[id] = (completer: completer, method: method);
final requestTimeout = timeout ?? const Duration(minutes: 3);
final stopwatch = Stopwatch()..start();
try {
@@ -163,7 +165,7 @@ final class CoreRpcClient implements CoreRpcChannel {
void _removePending(String id, Completer<Object?> completer) {
final removed = _pending.remove(id);
if (identical(removed, completer) && !completer.isCompleted) {
if (identical(removed?.completer, completer) && !completer.isCompleted) {
completer.complete(null);
}
}
@@ -208,8 +210,26 @@ final class CoreRpcClient implements CoreRpcChannel {
..reset()
..start();
final id = response.id;
final completer = id == null ? null : _pending.remove(id);
if (completer == null || completer.isCompleted) {
final pending = id == null ? null : _pending.remove(id);
final completer = pending?.completer;
if (pending == null || completer == null || completer.isCompleted) {
unawaited(discardSpilledResponse(response));
return;
}
if (response.file != null) {
unawaited(
resolveSpilledResponse(
response,
method: pending.method,
).then((resolved) => _complete(completer, resolved)),
);
return;
}
_complete(completer, response);
}
void _complete(Completer<Object?> completer, CoreMethodResponse response) {
if (completer.isCompleted) {
return;
}
final error = response.error;
@@ -256,7 +276,9 @@ final class CoreRpcClient implements CoreRpcChannel {
}
void _failPending(CoreMethodException error) {
final completers = _pending.values.toList(growable: false);
final completers = [
for (final pending in _pending.values) pending.completer,
];
_pending.clear();
for (final completer in completers) {
if (!completer.isCompleted) {
+11
View File
@@ -28,6 +28,8 @@ abstract mixin class CoreEventListener {
void onRequest(TrackerInfo connection) {}
void onRequestTraffic(Map<String, RequestTraffic> traffic) {}
void onDns(DnsQuery dnsQuery) {}
void onLoaded(String providerName) {}
@@ -63,6 +65,15 @@ class CoreEventManager {
case CoreEventType.request:
listener.onRequest(TrackerInfo.fromJson(event.data));
break;
case CoreEventType.requestTraffic:
listener.onRequestTraffic({
for (final Map item in event.data)
item['id'] as String: (
upload: (item['upload'] as num).toInt(),
download: (item['download'] as num).toInt(),
),
});
break;
case CoreEventType.dns:
listener.onDns(DnsQuery.fromJson(event.data));
break;
+53 -11
View File
@@ -38,6 +38,12 @@ mixin CoreInterface {
Future<Map<String, dynamic>> getConfig(String path);
Future<ProfileBase> getProfileBase(String path);
Future<Map<String, dynamic>> getProfileOutbounds(String path);
Future<String> buildProfile(SetupBase base);
Future<String> dumpRuleSet(String path);
Future<RuleSetInfo> compileRuleSet(String name, {String url = ''});
@@ -295,6 +301,51 @@ abstract class CoreHandlerInterface with CoreInterface {
return result;
}
@override
Future<ProfileBase> getProfileBase(String path) async {
final result = await _invokeMethod<Map<String, dynamic>>(
method: CoreMethod.getProfileBase,
arguments: path,
);
if (result == null) {
throw const CoreMethodException(
code: 'empty_result',
message: 'Core returned an empty profile base',
);
}
return ProfileBase.fromJson(result);
}
@override
Future<Map<String, dynamic>> getProfileOutbounds(String path) async {
final result = await _invokeMethod<Map<String, dynamic>>(
method: CoreMethod.getProfileOutbounds,
arguments: path,
);
if (result == null) {
throw const CoreMethodException(
code: 'empty_result',
message: 'Core returned no outbounds',
);
}
return result;
}
@override
Future<String> buildProfile(SetupBase base) async {
final result = await _invokeMethod<String>(
method: CoreMethod.buildProfile,
arguments: base.toJson(),
);
if (result == null || result.isEmpty) {
throw const CoreMethodException(
code: 'empty_result',
message: 'Core returned no built profile',
);
}
return result;
}
@override
Future<String> setupConfig(SetupParams setupParams) async {
return _invokeMessage(
@@ -419,17 +470,8 @@ abstract class CoreHandlerInterface with CoreInterface {
@override
Future<List<TrackerInfo>> getConnections() async {
final data = await _invokeMethod<Map<String, dynamic>>(
method: CoreMethod.getConnections,
);
final connections = data?['connections'];
if (connections is! List) {
return [];
}
return connections
.whereType<Map>()
.map((item) => TrackerInfo.fromJson(Map<String, Object?>.from(item)))
.toList();
final data = await _invokeMethod<Object>(method: CoreMethod.getConnections);
return data is List<TrackerInfo> ? data : trackerInfosOf(data);
}
@override
+15 -9
View File
@@ -9,6 +9,7 @@ import 'package:flutter/foundation.dart' show visibleForTesting;
import 'desktop/model.dart';
import 'interface.dart';
import 'method.dart';
import 'spill.dart';
class CoreLib extends CoreHandlerInterface {
static CoreLib? _instance;
@@ -111,18 +112,19 @@ class CoreLib extends CoreHandlerInterface {
return _stop(allowClosed: true);
}
// The service opens and closes the Core's listeners itself and reports a run
// that ends, so once it takes the command the Core's own answer is moot.
@override
Future<bool> startListener() async {
final listenerStarted = await super.startListener();
final serviceStarted = await _service?.start() ?? false;
return listenerStarted && serviceStarted;
await super.startListener();
return await _service?.start() ?? false;
}
@override
Future<bool> stopListener() async {
final serviceStopped = await _service?.stop() ?? false;
final listenerStopped = await super.stopListener();
return serviceStopped && listenerStopped;
await super.stopListener();
return serviceStopped;
}
@override
@@ -131,10 +133,14 @@ class CoreLib extends CoreHandlerInterface {
Object? arguments,
Duration? timeout,
}) {
return _invokeMethod<T>(
method: method,
arguments: arguments,
).withTimeout(timeout: timeout, onTimeout: () => null);
final call = _invokeMethod<T>(method: method, arguments: arguments);
return call.withTimeout(
timeout: timeout,
onTimeout: () {
unawaited(call.then(discardSpilledFile, onError: (_) {}));
return null;
},
);
}
Future<T?> _invokeMethod<T>({
+9 -1
View File
@@ -17,6 +17,9 @@ enum CoreMethod {
decodeShareLinks,
updateConfig,
getConfig,
getProfileBase,
getProfileOutbounds,
buildProfile,
dumpRuleSet,
compileRuleSet,
getProxies,
@@ -103,7 +106,10 @@ class CoreMethodResponse {
final Object? result;
final CoreMethodError? error;
const CoreMethodResponse({this.id, this.result, this.error});
/// Where the Core wrote the whole response instead; see `spill.dart`.
final String? file;
const CoreMethodResponse({this.id, this.result, this.error, this.file});
factory CoreMethodResponse.fromJson(Map<String, Object?> json) {
final error = json['error'];
@@ -113,6 +119,7 @@ class CoreMethodResponse {
error: error is Map
? CoreMethodError.fromJson(Map<String, Object?>.from(error))
: null,
file: json['file'] as String?,
);
}
@@ -121,6 +128,7 @@ class CoreMethodResponse {
if (id != null) 'id': id,
'result': result,
if (error != null) 'error': error!.toJson(),
if (file != null) 'file': file,
};
}
+112
View File
@@ -0,0 +1,112 @@
import 'dart:convert';
import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart';
import 'method.dart';
/// Mirrors spilledResponseDirName in core/spill.go.
const coreSpillDirectoryName = 'responses';
Future<bool> _isSpilled(String file) async {
final directory = join(await appPath.homeDirPath, coreSpillDirectoryName);
return equals(dirname(file), directory);
}
/// Parsed where a transport decodes them, off the UI isolate for a large one.
const _resultModels = <CoreMethod, Object? Function(Object?)>{
CoreMethod.getConnections: trackerInfosOf,
};
Map<String, Object?> withResultModel(
CoreMethod? method,
Map<String, Object?> response,
) {
final model = _resultModels[method];
if (model == null || response['error'] != null || response['file'] != null) {
return response;
}
return {...response, 'result': model(response['result'])};
}
Future<CoreMethodResponse> resolveSpilledResponse(
CoreMethodResponse response, {
CoreMethod? method,
}) async {
final file = response.file;
if (file == null) {
return response;
}
if (!await _isSpilled(file)) {
return CoreMethodResponse(
id: response.id,
error: CoreMethodError(
code: 'spill_error',
message: 'Core answered with a file outside its spill directory: $file',
),
);
}
try {
return CoreMethodResponse.fromJson(
await compute(_readSpilledResponse, (file, method)),
);
} catch (error) {
return CoreMethodResponse(
id: response.id,
error: CoreMethodError(code: 'spill_error', message: '$error'),
);
}
}
Map<String, Object?> _readSpilledResponse((String, CoreMethod?) request) {
final (path, method) = request;
final file = File(path);
try {
final decoded = utf8.decoder
.fuse(json.decoder)
.convert(file.readAsBytesSync());
return withResultModel(method, Map<String, Object?>.from(decoded as Map));
} finally {
try {
file.deleteSync();
} on FileSystemException catch (_) {}
}
}
Future<String> readSpilledText(String path) async {
if (!await _isSpilled(path)) {
throw CoreMethodException(
code: 'spill_error',
message: 'Core wrote a file outside its spill directory: $path',
);
}
return compute(_readSpilledText, path);
}
String _readSpilledText(String path) {
final file = File(path);
try {
return file.readAsStringSync();
} finally {
try {
file.deleteSync();
} on FileSystemException catch (_) {}
}
}
Future<void> discardSpilledResponse(CoreMethodResponse response) async {
await discardSpilledFile(response.file);
await discardSpilledFile(response.result);
}
Future<void> discardSpilledFile(Object? file) async {
if (file is! String || !await _isSpilled(file)) {
return;
}
try {
await File(file).delete();
} on FileSystemException catch (_) {}
}
+1 -1
View File
@@ -45,7 +45,7 @@ class ProxyGroupsDao extends DatabaseAccessor<Database>
return stmt.map((item) => item.toProxyGroup());
}
Selectable<int> count(int profileId) {
Selectable<int> profileGroupsCount(int profileId) {
final stmt = proxyGroups.select();
stmt.where((row) => row.profileId.equals(profileId));
return stmt.count;
+39 -99
View File
@@ -4,7 +4,9 @@ import 'dart:io';
import 'package:fl_clash/common/context.dart';
import 'package:fl_clash/common/system.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/ui/color.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/ui/surface.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter/services.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -119,35 +121,23 @@ enum ViewMode { mobile, laptop, desktop }
enum LogLevel { debug, info, warning, error, silent }
enum RecordTone { muted, neutral, warning, error }
enum RecordTone { neutral, warning, error }
extension RecordToneExt on RecordTone {
Color? accentColor(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return switch (this) {
RecordTone.warning => colorScheme.tertiary,
RecordTone.warning => colorScheme.warning,
RecordTone.error => colorScheme.error,
RecordTone.muted || RecordTone.neutral => null,
};
}
Color? tintColor(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return switch (this) {
RecordTone.warning => colorScheme.tertiaryContainer.withValues(
alpha: 0.2,
),
RecordTone.error => colorScheme.errorContainer.withValues(alpha: 0.2),
RecordTone.muted || RecordTone.neutral => null,
RecordTone.neutral => null,
};
}
Color labelColor(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return switch (this) {
RecordTone.muted => colorScheme.outline,
RecordTone.neutral => colorScheme.onSurfaceVariant,
RecordTone.warning => colorScheme.onTertiaryContainer,
RecordTone.warning => colorScheme.onWarningContainer,
RecordTone.error => colorScheme.onErrorContainer,
};
}
@@ -155,9 +145,8 @@ extension RecordToneExt on RecordTone {
Color labelContainerColor(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return switch (this) {
RecordTone.muted => colorScheme.surfaceContainerHigh,
RecordTone.neutral => colorScheme.surfaceContainerHighest,
RecordTone.warning => colorScheme.tertiaryContainer,
RecordTone.neutral => context.raisedSurfaceLevel.colorOf(colorScheme),
RecordTone.warning => colorScheme.warningContainer,
RecordTone.error => colorScheme.errorContainer,
};
}
@@ -179,7 +168,7 @@ extension MessageLevelExt on MessageLevel {
final colorScheme = Theme.of(context).colorScheme;
return switch (this) {
MessageLevel.error => colorScheme.errorContainer,
_ => colorScheme.surfaceContainerHigh,
_ => SurfaceLevel.floatingOf(colorScheme).colorOf(colorScheme),
};
}
@@ -195,8 +184,8 @@ extension MessageLevelExt on MessageLevel {
final colorScheme = Theme.of(context).colorScheme;
return switch (this) {
MessageLevel.info => colorScheme.onSurfaceVariant,
MessageLevel.success => colorScheme.primary,
MessageLevel.warning => colorScheme.tertiary,
MessageLevel.success => colorScheme.success,
MessageLevel.warning => colorScheme.warning,
MessageLevel.error => colorScheme.onErrorContainer,
};
}
@@ -235,6 +224,7 @@ enum CoreEventType {
log,
delay,
request,
requestTraffic,
dns,
loaded,
crash,
@@ -249,13 +239,11 @@ enum InterfaceNameMode { clear, follow, custom }
enum RestoreOption { all, onlyProfiles }
enum CommonCardType { plain, filled }
enum ProxiesType { tab, list }
enum ProxiesLayout { loose, standard, tight }
enum ProxyCardType { expand, shrink, min }
enum ProxyCardType { loose, standard, compact }
enum DnsMode {
normal,
@@ -332,6 +320,7 @@ enum DnsOverrideKey with OverrideKey {
fakeIpFilterMode,
defaultNameserver,
nameserver,
fallback,
proxyServerNameserver,
directNameserver,
};
@@ -375,11 +364,24 @@ enum SnifferOverrideKey with OverrideKey {
}
/// Written over the profile's `tun` section, which Android's VPN never reads,
/// so these apply on desktop only. The network settings' `Tun` holds the rest.
/// so these apply on desktop only, beside the keys of `Tun.coreJson`.
@JsonEnum(valueField: 'path')
enum TunOverrideKey with OverrideKey {
mtu('mtu'),
congestionController('congestion-controller'),
strictRoute('strict-route'),
routeAddress('route-address'),
routeExcludeAddress('route-exclude-address'),
dnsHijack('dns-hijack'),
udpTimeout('udp-timeout'),
disableIcmpForwarding('disable-icmp-forwarding'),
excludeInterface('exclude-interface');
gso('gso'),
includeInterface('include-interface'),
excludeInterface('exclude-interface'),
includeUid('include-uid'),
excludeUid('exclude-uid'),
excludeSrcPort('exclude-src-port'),
excludeDstPort('exclude-dst-port');
const TunOverrideKey(this.path);
@@ -428,15 +430,6 @@ enum HotAction {
enum ProxiesIconStyle { filled, plain, hidden }
enum FontFamily {
twEmoji('Twemoji'),
jetBrainsMono('JetBrainsMono');
final String value;
const FontFamily(this.value);
}
enum EditorFontSize {
standard(16),
large(18),
@@ -453,24 +446,17 @@ enum TunAuthorizationState { none, authorized, unauthorized }
enum FunctionTag {
updateConfig,
setupConfig,
updateGroups,
applyProfile,
savePreferences,
changeProxy,
handleWill,
updateDelay,
autoLaunch,
updatePageIndex,
pageChange,
proxiesTabChange,
logs,
requests,
dnsQueries,
autoScrollToEnd,
loadedProvider,
saveSharedFile,
removeProxy,
suspend,
coreErrorNotifier,
dialerLoopNotifier,
@@ -478,22 +464,17 @@ enum FunctionTag {
}
enum DashboardWidget {
currentOutbound,
networkSpeed,
outboundMode,
trafficUsage,
networkDetection,
tunButton(platforms: desktopPlatforms),
vpnButton(platforms: [SupportPlatform.Android]),
systemProxyButton(platforms: desktopPlatforms),
intranetIp,
memoryInfo,
networkTakeover(platforms: desktopPlatforms),
serviceStatus,
dnsQueries,
requests,
connections,
runTime,
profiles,
proxyGroups,
profiles;
connectionStats,
intranetIp,
memoryInfo;
final List<SupportPlatform> platforms;
@@ -825,9 +806,11 @@ enum RestoreStrategy { compatible, override }
enum TabAnimation { slide, fade }
enum LiquidGlassMode { none, standard, ultra }
enum Language { yaml, javaScript, json }
enum ScrollPositionCacheKey { tools, profiles, proxiesList, proxiesTabList }
enum ScrollPositionCacheKey { proxiesTabList }
enum QueryTag { proxies, access }
@@ -845,46 +828,3 @@ enum LoadingTag {
enum CoreStatus { connecting, connected, disconnected }
enum UpdatingScope { core, local }
enum ItemPosition {
start,
middle,
end,
startAndEnd;
static ItemPosition get(int index, int length) {
ItemPosition position = ItemPosition.middle;
if (length == 1) {
position = ItemPosition.startAndEnd;
} else if (index == length - 1) {
position = ItemPosition.end;
} else if (index == 0) {
position = ItemPosition.start;
}
return position;
}
static ItemPosition calculateVisualPosition<T>(
int currentIndex,
List<T> items,
Set<T> deletedItems,
) {
if (deletedItems.isEmpty) {
return ItemPosition.get(currentIndex, items.length);
}
final currentItem = items[currentIndex];
if (deletedItems.contains(currentItem)) {
return ItemPosition.middle;
}
final int visualLength = items.length - deletedItems.length;
if (visualLength <= 0) return ItemPosition.middle;
int deletedCountBeforeMe = 0;
for (int i = 0; i < currentIndex; i++) {
if (deletedItems.contains(items[i])) {
deletedCountBeforeMe++;
}
}
final int visualIndex = currentIndex - deletedCountBeforeMe;
return ItemPosition.get(visualIndex, visualLength);
}
}
+1
View File
@@ -1,3 +1,4 @@
export 'tracker_info_item.dart';
export 'tracker_info_list.dart';
export 'tracker_info_table.dart';
export 'tracker_speed_ranker.dart';

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