Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8655b0809 | |||
| 6c52d7a39a | |||
| 4d68938d22 | |||
| 213a13f62f | |||
| fefbcf4598 | |||
| 9f48781341 | |||
| dd952dd600 | |||
| bdbf4ab024 | |||
| 347a74b940 | |||
| eb47ee261f | |||
| e82ebf785b | |||
| ba15c313b4 | |||
| 568c49bb3a |
+456
-90
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -371,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 }}"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
/**
|
||||
@@ -180,10 +199,6 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
result.success(openAppSettings())
|
||||
}
|
||||
|
||||
"didCrashOnPreviousExecution" -> reply(result) {
|
||||
GlobalState.didCrashOnPreviousExecution()
|
||||
}
|
||||
|
||||
"getLastExitInfo" -> reply(result) {
|
||||
GlobalState.lastExitInfo()
|
||||
}
|
||||
@@ -431,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
|
||||
@@ -441,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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(" ")
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'
|
||||
@@ -227,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
@@ -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), ¶ms); 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
@@ -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() {
|
||||
|
||||
+13
-10
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
+7
-13
@@ -1,7 +1,6 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/metacubex/http"
|
||||
@@ -76,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 {
|
||||
@@ -146,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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+16
-57
@@ -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!),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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] ?? '',
|
||||
);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -46,6 +46,16 @@ class Preferences {
|
||||
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 _store.value;
|
||||
await preferences?.setString('sharedState', json.encode(shareState));
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
+16
-2
@@ -207,11 +207,25 @@ Future<RealProfile> _makeRealProfileTask(MakeRealProfileState data) async {
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -35,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;
|
||||
|
||||
@@ -76,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 {
|
||||
@@ -164,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);
|
||||
}
|
||||
}
|
||||
@@ -209,8 +210,9 @@ 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;
|
||||
}
|
||||
@@ -218,6 +220,7 @@ final class CoreRpcClient implements CoreRpcChannel {
|
||||
unawaited(
|
||||
resolveSpilledResponse(
|
||||
response,
|
||||
method: pending.method,
|
||||
).then((resolved) => _complete(completer, resolved)),
|
||||
);
|
||||
return;
|
||||
@@ -273,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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2
-11
@@ -470,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
|
||||
|
||||
+24
-5
@@ -2,6 +2,7 @@ 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';
|
||||
|
||||
@@ -15,9 +16,26 @@ Future<bool> _isSpilled(String file) async {
|
||||
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,
|
||||
) async {
|
||||
CoreMethodResponse response, {
|
||||
CoreMethod? method,
|
||||
}) async {
|
||||
final file = response.file;
|
||||
if (file == null) {
|
||||
return response;
|
||||
@@ -33,7 +51,7 @@ Future<CoreMethodResponse> resolveSpilledResponse(
|
||||
}
|
||||
try {
|
||||
return CoreMethodResponse.fromJson(
|
||||
await compute(_readSpilledResponse, file),
|
||||
await compute(_readSpilledResponse, (file, method)),
|
||||
);
|
||||
} catch (error) {
|
||||
return CoreMethodResponse(
|
||||
@@ -43,13 +61,14 @@ Future<CoreMethodResponse> resolveSpilledResponse(
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object?> _readSpilledResponse(String path) {
|
||||
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 Map<String, Object?>.from(decoded as Map);
|
||||
return withResultModel(method, Map<String, Object?>.from(decoded as Map));
|
||||
} finally {
|
||||
try {
|
||||
file.deleteSync();
|
||||
|
||||
@@ -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
@@ -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,3 +1,4 @@
|
||||
export 'tracker_info_item.dart';
|
||||
export 'tracker_info_list.dart';
|
||||
export 'tracker_info_table.dart';
|
||||
export 'tracker_speed_ranker.dart';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/config.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -26,6 +26,22 @@ String _endpointText(String ip, String port) {
|
||||
return ip;
|
||||
}
|
||||
|
||||
void showTrackerInfoDetail(
|
||||
BuildContext context,
|
||||
TrackerInfo trackerInfo,
|
||||
String title,
|
||||
) {
|
||||
showInSheet<void>(
|
||||
context,
|
||||
builder: (_) {
|
||||
return CommonScaffold(
|
||||
body: TrackerInfoDetailView(trackerInfo: trackerInfo),
|
||||
title: title,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class TrackerInfoItem extends ConsumerWidget {
|
||||
final TrackerInfo trackerInfo;
|
||||
final bool isLive;
|
||||
@@ -51,17 +67,7 @@ class TrackerInfoItem extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
return RecordListItem(
|
||||
onTap: () {
|
||||
showInSheet<void>(
|
||||
context,
|
||||
builder: (_) {
|
||||
return CommonScaffold(
|
||||
body: TrackerInfoDetailView(trackerInfo: trackerInfo),
|
||||
title: detailTitle,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
onTap: () => showTrackerInfoDetail(context, trackerInfo, detailTitle),
|
||||
header: _buildHeader(context),
|
||||
body: _TrackerInfoBody(
|
||||
trackerInfo: trackerInfo,
|
||||
@@ -74,7 +80,9 @@ class TrackerInfoItem extends ConsumerWidget {
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
final network = Text(
|
||||
trackerInfo.metadata.network.toUpperCase(),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
style: AppTextStyle.caption1
|
||||
.of(context, emphasized: true)
|
||||
.copyWith(color: context.colorScheme.onSurfaceVariant),
|
||||
);
|
||||
if (!isLive) {
|
||||
return RecordHeader(
|
||||
@@ -135,10 +143,13 @@ class _TrackerInfoBody extends StatelessWidget {
|
||||
children: [
|
||||
TextSpan(
|
||||
text: _endpointText(trackerInfo.title, metadata.destinationPort),
|
||||
style: styles.primary?.copyWith(fontWeight: FontWeight.w500),
|
||||
style: styles.title,
|
||||
),
|
||||
if (metadata.host.isNotEmpty && metadata.destinationIP.isNotEmpty)
|
||||
TextSpan(text: ' ${metadata.destinationIP}', style: styles.muted),
|
||||
TextSpan(
|
||||
text: ' ${metadata.destinationIP}',
|
||||
style: styles.secondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -188,8 +199,8 @@ class _TrackerInfoBody extends StatelessWidget {
|
||||
Flexible(
|
||||
child: TonalChip(
|
||||
label: chain,
|
||||
color: colorScheme.secondaryContainer,
|
||||
foregroundColor: colorScheme.onSecondaryContainer,
|
||||
color: context.raisedSurfaceLevel.colorOf(colorScheme),
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
onPressed: () => onClickKeyword?.call(chain),
|
||||
),
|
||||
),
|
||||
@@ -197,7 +208,7 @@ class _TrackerInfoBody extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (source.isNotEmpty) Text(source, style: styles.muted),
|
||||
if (source.isNotEmpty) Text(source, style: styles.secondary),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -247,7 +258,7 @@ class TrackerInfoDetailView extends StatelessWidget {
|
||||
horizontal: 16,
|
||||
).copyWith(bottom: 20, top: context.contentTopPadding),
|
||||
children: [
|
||||
generateSectionV3(
|
||||
generateSection(
|
||||
title: appLocalizations.basicInfo,
|
||||
items: _buildRows([
|
||||
(appLocalizations.creationTime, trackerInfo.start.showFull),
|
||||
@@ -258,7 +269,7 @@ class TrackerInfoDetailView extends StatelessWidget {
|
||||
(appLocalizations.download, trackerInfo.download.traffic.show),
|
||||
]),
|
||||
),
|
||||
generateSectionV3(
|
||||
generateSection(
|
||||
title: appLocalizations.address,
|
||||
items: _buildRows([
|
||||
(appLocalizations.host, metadata.host),
|
||||
@@ -278,7 +289,7 @@ class TrackerInfoDetailView extends StatelessWidget {
|
||||
(appLocalizations.remoteDestination, metadata.remoteDestination),
|
||||
]),
|
||||
),
|
||||
generateSectionV3(
|
||||
generateSection(
|
||||
title: appLocalizations.proxies,
|
||||
items: [
|
||||
..._buildRows([
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:material_ui/material_ui.dart';
|
||||
import 'package:super_sliver_list/super_sliver_list.dart';
|
||||
|
||||
import 'tracker_info_item.dart';
|
||||
import 'tracker_info_table.dart';
|
||||
|
||||
class TrackerInfoListController extends ValueNotifier<TrackerInfosState> {
|
||||
TrackerInfoListController() : super(const TrackerInfosState());
|
||||
@@ -28,6 +29,7 @@ class TrackerInfoListController extends ValueNotifier<TrackerInfosState> {
|
||||
value.trackerInfos,
|
||||
trackerInfos,
|
||||
pausedMaxRequestsLength,
|
||||
keyOf: (trackerInfo) => trackerInfo.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -44,6 +46,7 @@ class TrackerInfoListController extends ValueNotifier<TrackerInfosState> {
|
||||
class TrackerInfoList extends StatelessWidget {
|
||||
final List<TrackerInfo> trackerInfos;
|
||||
final String detailTitle;
|
||||
final RecordTable<TrackerInfo>? table;
|
||||
final ScrollController? controller;
|
||||
final bool reverse;
|
||||
final bool shrinkWrap;
|
||||
@@ -55,6 +58,7 @@ class TrackerInfoList extends StatelessWidget {
|
||||
super.key,
|
||||
required this.trackerInfos,
|
||||
required this.detailTitle,
|
||||
this.table,
|
||||
this.controller,
|
||||
this.reverse = false,
|
||||
this.shrinkWrap = false,
|
||||
@@ -72,13 +76,17 @@ class TrackerInfoList extends StatelessWidget {
|
||||
controller: controller,
|
||||
padding: padding,
|
||||
itemCount: trackerInfos.length,
|
||||
extentEstimation: zeroDividerExtentEstimation,
|
||||
separatorBuilder: (_, _) => const Divider(height: 0),
|
||||
extentEstimation: table?.estimateExtent ?? recordListExtentEstimation,
|
||||
separatorBuilder: (_, _) => table == null
|
||||
? const SizedBox(height: recordListGap)
|
||||
: const SizedBox.shrink(),
|
||||
itemBuilder: (_, index) => _buildTrackerInfoItem(
|
||||
context,
|
||||
trackerInfos[index],
|
||||
isLive: false,
|
||||
detailTitle: detailTitle,
|
||||
table: table,
|
||||
striped: index.isOdd,
|
||||
actionBuilder: actionBuilder,
|
||||
),
|
||||
);
|
||||
@@ -90,6 +98,7 @@ class TrackerInfoList extends StatelessWidget {
|
||||
class TrackerInfoAnimatedList extends StatelessWidget {
|
||||
final List<TrackerInfo> trackerInfos;
|
||||
final String detailTitle;
|
||||
final RecordTable<TrackerInfo>? table;
|
||||
final ScrollController? controller;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final Widget? Function(TrackerInfo trackerInfo)? actionBuilder;
|
||||
@@ -98,6 +107,7 @@ class TrackerInfoAnimatedList extends StatelessWidget {
|
||||
super.key,
|
||||
required this.trackerInfos,
|
||||
required this.detailTitle,
|
||||
this.table,
|
||||
this.controller,
|
||||
this.padding,
|
||||
this.actionBuilder,
|
||||
@@ -105,17 +115,26 @@ class TrackerInfoAnimatedList extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final table = this.table;
|
||||
final positions = table == null
|
||||
? const <String, int>{}
|
||||
: {
|
||||
for (final (index, trackerInfo) in trackerInfos.indexed)
|
||||
trackerInfo.id: index,
|
||||
};
|
||||
return KeyedAnimatedList<TrackerInfo>(
|
||||
controller: controller,
|
||||
padding: padding,
|
||||
items: trackerInfos,
|
||||
keyOf: (trackerInfo) => trackerInfo.id,
|
||||
separator: const Divider(height: 0),
|
||||
separator: table == null ? const SizedBox(height: recordListGap) : null,
|
||||
itemBuilder: (context, trackerInfo) => _buildTrackerInfoItem(
|
||||
context,
|
||||
trackerInfo,
|
||||
isLive: true,
|
||||
detailTitle: detailTitle,
|
||||
table: table,
|
||||
striped: positions[trackerInfo.id]?.isOdd ?? false,
|
||||
actionBuilder: actionBuilder,
|
||||
),
|
||||
);
|
||||
@@ -127,8 +146,19 @@ Widget _buildTrackerInfoItem(
|
||||
TrackerInfo trackerInfo, {
|
||||
required bool isLive,
|
||||
required String detailTitle,
|
||||
required RecordTable<TrackerInfo>? table,
|
||||
required bool striped,
|
||||
required Widget? Function(TrackerInfo trackerInfo)? actionBuilder,
|
||||
}) {
|
||||
if (table != null) {
|
||||
return TrackerInfoTableRow(
|
||||
key: Key(trackerInfo.id),
|
||||
trackerInfo: trackerInfo,
|
||||
table: table,
|
||||
striped: striped,
|
||||
detailTitle: detailTitle,
|
||||
);
|
||||
}
|
||||
return TrackerInfoItem(
|
||||
key: Key(trackerInfo.id),
|
||||
trackerInfo: trackerInfo,
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import 'tracker_info_item.dart';
|
||||
|
||||
const _fullLiveTableWidth = 1200.0;
|
||||
|
||||
List<RecordColumn<TrackerInfo>> trackerInfoColumns(
|
||||
BuildContext context, {
|
||||
required double width,
|
||||
required bool isLive,
|
||||
Widget? Function(TrackerInfo trackerInfo)? actionBuilder,
|
||||
}) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final wide = width >= recordWideTableWidth;
|
||||
// A live table spends its first extra width on the totals.
|
||||
final detailed = wide && (!isLive || width >= _fullLiveTableWidth);
|
||||
double totalWidth(String label) => max(88, recordHeaderWidth(context, label));
|
||||
return [
|
||||
if (!isLive)
|
||||
RecordColumn(
|
||||
TableColumn('time', appLocalizations.time, width: 80),
|
||||
cell: (_, info) =>
|
||||
RecordCellText(info.start.showTime.trim(), muted: true),
|
||||
sortKey: (info) => info.start,
|
||||
descendingFirst: true,
|
||||
),
|
||||
RecordColumn(
|
||||
TableColumn('process', appLocalizations.process, width: wide ? 160 : 140),
|
||||
cell: (_, info) => RecordCellText(info.progressText),
|
||||
sortKey: (info) => info.progressText.toLowerCase(),
|
||||
),
|
||||
RecordColumn(
|
||||
TableColumn('target', appLocalizations.target, flex: wide ? 1.4 : 1.3),
|
||||
cell: (_, info) => TrackerTargetText(info),
|
||||
sortKey: (info) => info.title.toLowerCase(),
|
||||
),
|
||||
if (detailed)
|
||||
RecordColumn(
|
||||
TableColumn(
|
||||
'protocol',
|
||||
appLocalizations.protocol,
|
||||
width: max(56, recordHeaderWidth(context, appLocalizations.protocol)),
|
||||
),
|
||||
cell: (_, info) =>
|
||||
RecordCellText(info.metadata.network.toUpperCase(), muted: true),
|
||||
sortKey: (info) => info.metadata.network.toLowerCase(),
|
||||
),
|
||||
if (wide || !isLive)
|
||||
RecordColumn(
|
||||
TableColumn('rule', appLocalizations.rule, flex: 1),
|
||||
cell: (_, info) => TrackerRuleText(info),
|
||||
sortKey: (info) => '${info.rule} ${info.rulePayload}'.toLowerCase(),
|
||||
),
|
||||
RecordColumn(
|
||||
TableColumn('exit', appLocalizations.outboundExit, width: 120),
|
||||
cell: (_, info) => _ExitChip(exit: info.chains.firstOrNull ?? ''),
|
||||
sortKey: (info) => (info.chains.firstOrNull ?? '').toLowerCase(),
|
||||
),
|
||||
if (isLive) ...[
|
||||
_amountColumn(
|
||||
'downloadSpeed',
|
||||
appLocalizations.download,
|
||||
(info) => info.downloadSpeed ?? 0,
|
||||
perSecond: true,
|
||||
),
|
||||
_amountColumn(
|
||||
'uploadSpeed',
|
||||
appLocalizations.upload,
|
||||
(info) => info.uploadSpeed ?? 0,
|
||||
perSecond: true,
|
||||
),
|
||||
],
|
||||
if (isLive && wide) ...[
|
||||
_amountColumn(
|
||||
'download',
|
||||
appLocalizations.totalDownload,
|
||||
(info) => info.download,
|
||||
width: totalWidth(appLocalizations.totalDownload),
|
||||
),
|
||||
_amountColumn(
|
||||
'upload',
|
||||
appLocalizations.totalUpload,
|
||||
(info) => info.upload,
|
||||
width: totalWidth(appLocalizations.totalUpload),
|
||||
),
|
||||
] else if (wide) ...[
|
||||
_amountColumn(
|
||||
'download',
|
||||
appLocalizations.download,
|
||||
(info) => info.download,
|
||||
),
|
||||
_amountColumn('upload', appLocalizations.upload, (info) => info.upload),
|
||||
] else
|
||||
_amountColumn(
|
||||
'traffic',
|
||||
appLocalizations.traffic,
|
||||
(info) => info.upload + info.download,
|
||||
width: 88,
|
||||
),
|
||||
if (isLive && detailed)
|
||||
RecordColumn(
|
||||
TableColumn(
|
||||
'duration',
|
||||
appLocalizations.duration,
|
||||
width: 80,
|
||||
end: true,
|
||||
),
|
||||
cell: (_, info) => RecordCellText(
|
||||
trackerDurationText(DateTime.now().difference(info.start)),
|
||||
muted: true,
|
||||
),
|
||||
sortKey: (info) => -info.start.microsecondsSinceEpoch,
|
||||
descendingFirst: true,
|
||||
),
|
||||
if (actionBuilder != null)
|
||||
RecordColumn(
|
||||
const TableColumn('action', '', width: 44, resizable: false),
|
||||
cell: (_, info) => actionBuilder(info) ?? const SizedBox.shrink(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
RecordColumn<TrackerInfo> _amountColumn(
|
||||
String id,
|
||||
String label,
|
||||
int Function(TrackerInfo info) amountOf, {
|
||||
double width = 100,
|
||||
bool perSecond = false,
|
||||
}) {
|
||||
return RecordColumn(
|
||||
TableColumn(id, label, width: width, end: true),
|
||||
cell: (_, info) {
|
||||
final amount = amountOf(info);
|
||||
return RecordCellText(
|
||||
perSecond ? '${amount.traffic.show}/s' : amount.traffic.show,
|
||||
muted: perSecond && amount == 0,
|
||||
);
|
||||
},
|
||||
sortKey: amountOf,
|
||||
descendingFirst: true,
|
||||
);
|
||||
}
|
||||
|
||||
String trackerDurationText(Duration duration) {
|
||||
final seconds = duration.inSeconds;
|
||||
if (seconds < 60) {
|
||||
return '${seconds < 0 ? 0 : seconds} s';
|
||||
}
|
||||
final minutes = seconds / 60;
|
||||
if (minutes < 60) {
|
||||
return '${minutes < 10 ? minutes.toStringAsFixed(1) : minutes.round()} min';
|
||||
}
|
||||
return '${(minutes / 60).toStringAsFixed(1)} h';
|
||||
}
|
||||
|
||||
class TrackerInfoTableRow extends StatelessWidget {
|
||||
final TrackerInfo trackerInfo;
|
||||
final RecordTable<TrackerInfo> table;
|
||||
final bool striped;
|
||||
final String detailTitle;
|
||||
|
||||
const TrackerInfoTableRow({
|
||||
super.key,
|
||||
required this.trackerInfo,
|
||||
required this.table,
|
||||
required this.striped,
|
||||
required this.detailTitle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RecordTableRow(
|
||||
table: table,
|
||||
striped: striped,
|
||||
onTap: () => showTrackerInfoDetail(context, trackerInfo, detailTitle),
|
||||
cells: table.cellsOf(context, trackerInfo),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ExitChip extends StatelessWidget {
|
||||
final String exit;
|
||||
|
||||
const _ExitChip({required this.exit});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (exit.isEmpty) {
|
||||
return const RecordCellText('');
|
||||
}
|
||||
final colorScheme = context.colorScheme;
|
||||
return TonalChip(
|
||||
label: exit,
|
||||
color: context.raisedSurfaceLevel.colorOf(colorScheme),
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
onPressed: () => context.commonScaffoldState?.addKeyword(exit),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TrackerTargetText extends StatelessWidget {
|
||||
final TrackerInfo trackerInfo;
|
||||
|
||||
const TrackerTargetText(this.trackerInfo, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final port = trackerInfo.metadata.destinationPort;
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: trackerInfo.title),
|
||||
if (port.isNotEmpty)
|
||||
TextSpan(
|
||||
text: ':$port',
|
||||
style: TextStyle(color: context.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TrackerRuleText extends StatelessWidget {
|
||||
final TrackerInfo trackerInfo;
|
||||
|
||||
const TrackerRuleText(this.trackerInfo, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: trackerInfo.rule,
|
||||
style: TextStyle(color: context.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
if (trackerInfo.rulePayload.isNotEmpty)
|
||||
TextSpan(text: ' ${trackerInfo.rulePayload}'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -40,10 +40,13 @@ class TrackerSpeedRanker {
|
||||
..clear()
|
||||
..addAll(samples);
|
||||
|
||||
int bucketOf(TrackerInfo trackerInfo) {
|
||||
final speed = samples[trackerInfo.id]!.rankSpeed;
|
||||
return speed < 1 ? 0 : (log(speed) / ln2).floor() + 1;
|
||||
}
|
||||
final buckets = {
|
||||
for (final MapEntry(key: id, value: sample) in samples.entries)
|
||||
id: sample.rankSpeed < 1
|
||||
? 0
|
||||
: (log(sample.rankSpeed) / ln2).floor() + 1,
|
||||
};
|
||||
int bucketOf(TrackerInfo trackerInfo) => buckets[trackerInfo.id]!;
|
||||
|
||||
final ranked =
|
||||
[
|
||||
|
||||
@@ -88,7 +88,7 @@ enum EditorSchema {
|
||||
ntp(_ntp, 'ntp'),
|
||||
sniffer(_sniffer, 'sniffer'),
|
||||
proxy(_proxy),
|
||||
proxyGroup(_proxyGroup, 'proxy-groups'),
|
||||
proxyGroup(_customProxyGroup, 'proxy-groups'),
|
||||
rules(_rules),
|
||||
shareLinks(_str),
|
||||
provider(_providerFile);
|
||||
@@ -1282,51 +1282,50 @@ const _healthCheck = YamlSchema.map({
|
||||
'expected-status': _str,
|
||||
});
|
||||
|
||||
const _groupFilters = {
|
||||
'include-all': _bool,
|
||||
'include-all-proxies': _bool,
|
||||
'include-all-providers': _bool,
|
||||
const _proxyGroupFields = {
|
||||
'name': _str,
|
||||
'type': YamlSchema.scalar(['select', 'url-test', 'fallback', 'load-balance']),
|
||||
'proxies': YamlSchema.list(_policy),
|
||||
'use': YamlSchema.list(YamlSchema.of(YamlScalar.proxyProvider)),
|
||||
'use-position': YamlSchema.dict(_str),
|
||||
'url': YamlSchema.scalar(['https://www.gstatic.com/generate_204']),
|
||||
'interval': _str,
|
||||
'lazy': _bool,
|
||||
'timeout': _str,
|
||||
'max-failed-times': _str,
|
||||
'empty-fallback': _policy,
|
||||
'expected-status': _str,
|
||||
'filter': _str,
|
||||
'exclude-filter': _str,
|
||||
'exclude-type': _str,
|
||||
'disable-udp': _bool,
|
||||
'hidden': _bool,
|
||||
'icon': _str,
|
||||
};
|
||||
|
||||
const _proxyGroup = YamlSchema.map(
|
||||
{
|
||||
'name': _str,
|
||||
'type': YamlSchema.scalar([
|
||||
'select',
|
||||
'url-test',
|
||||
'fallback',
|
||||
'load-balance',
|
||||
const _proxyGroupVariants = {
|
||||
'select': {'default-selected': _policy},
|
||||
'url-test': {'tolerance': _str},
|
||||
'load-balance': {
|
||||
'strategy': YamlSchema.scalar([
|
||||
'consistent-hashing',
|
||||
'round-robin',
|
||||
'sticky-sessions',
|
||||
]),
|
||||
'proxies': YamlSchema.list(_policy),
|
||||
'use': YamlSchema.list(YamlSchema.of(YamlScalar.proxyProvider)),
|
||||
'use-position': YamlSchema.dict(_str),
|
||||
'url': YamlSchema.scalar(['https://www.gstatic.com/generate_204']),
|
||||
'interval': _str,
|
||||
'lazy': _bool,
|
||||
'timeout': _str,
|
||||
'max-failed-times': _str,
|
||||
'empty-fallback': _policy,
|
||||
'expected-status': _str,
|
||||
..._groupFilters,
|
||||
'disable-udp': _bool,
|
||||
'hidden': _bool,
|
||||
'icon': _str,
|
||||
},
|
||||
variants: {
|
||||
'select': {'default-selected': _policy},
|
||||
'url-test': {'tolerance': _str},
|
||||
'load-balance': {
|
||||
'strategy': YamlSchema.scalar([
|
||||
'consistent-hashing',
|
||||
'round-robin',
|
||||
'sticky-sessions',
|
||||
]),
|
||||
'hash-key': YamlSchema.scalar(['in-user']),
|
||||
},
|
||||
'hash-key': YamlSchema.scalar(['in-user']),
|
||||
},
|
||||
};
|
||||
|
||||
const _proxyGroup = YamlSchema.map({
|
||||
..._proxyGroupFields,
|
||||
'include-all': _bool,
|
||||
'include-all-proxies': _bool,
|
||||
'include-all-providers': _bool,
|
||||
}, variants: _proxyGroupVariants);
|
||||
|
||||
const _customProxyGroup = YamlSchema.map(
|
||||
_proxyGroupFields,
|
||||
variants: _proxyGroupVariants,
|
||||
);
|
||||
|
||||
const _providerType = YamlSchema.scalar(['http', 'file', 'inline']);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:code_forge/code_forge.dart' show CodeForgeSuggestionDetails;
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/ui/ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
const _suggestionInset = 8.0;
|
||||
@@ -125,7 +125,7 @@ class _EditorCompletionPopupState extends State<EditorCompletionPopup> {
|
||||
horizontal: _suggestionItemPadding,
|
||||
),
|
||||
decoration: ShapeDecoration(
|
||||
color: selected ? colorScheme.secondaryContainer : null,
|
||||
color: selected ? colorScheme.primaryContainer : null,
|
||||
shape: _suggestionItemShape,
|
||||
),
|
||||
child: Row(
|
||||
@@ -138,7 +138,7 @@ class _EditorCompletionPopupState extends State<EditorCompletionPopup> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: widget.textStyle.copyWith(
|
||||
color: selected
|
||||
? colorScheme.onSecondaryContainer
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
@@ -155,7 +155,7 @@ class _EditorCompletionPopupState extends State<EditorCompletionPopup> {
|
||||
textAlign: TextAlign.end,
|
||||
style: _detailStyle.copyWith(
|
||||
color: selected
|
||||
? colorScheme.onSecondaryContainer
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
@@ -181,7 +181,9 @@ class _EditorCompletionPopupState extends State<EditorCompletionPopup> {
|
||||
return Material(
|
||||
type: MaterialType.card,
|
||||
elevation: 8,
|
||||
color: context.colorScheme.surfaceContainer,
|
||||
color: SurfaceLevel.floatingOf(
|
||||
context.colorScheme,
|
||||
).colorOf(context.colorScheme),
|
||||
textStyle: widget.textStyle,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: _suggestionShape,
|
||||
|
||||
@@ -56,8 +56,6 @@ class _Frame {
|
||||
|
||||
_Frame(this.schema, this.indent, this.ownerIndent);
|
||||
|
||||
bool get isDict => schema.entry != null;
|
||||
|
||||
MapEntry<String, YamlSchema>? field(String name) {
|
||||
final lower = name.toLowerCase();
|
||||
return schema
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A form's batch mode: one field of lines, and how many of them it adds.
|
||||
class BatchSection<T> extends StatelessWidget {
|
||||
final BatchInput<T> batch;
|
||||
final TextEditingController controller;
|
||||
final ParsedInput<T>? parsed;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
const BatchSection({
|
||||
super.key,
|
||||
required this.batch,
|
||||
required this.controller,
|
||||
required this.parsed,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final parsed = this.parsed;
|
||||
return generateSection(
|
||||
footer: parsed == null || controller.text.trim().isEmpty
|
||||
? null
|
||||
: context.appLocalizations.batchPreviewTip(
|
||||
parsed.entries.length,
|
||||
parsed.skippedExisting,
|
||||
),
|
||||
items: [
|
||||
DecorationListItem(
|
||||
title: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
minLines: 4,
|
||||
maxLines: null,
|
||||
keyboardType: TextInputType.multiline,
|
||||
autocorrect: false,
|
||||
onChanged: onChanged,
|
||||
decoration: InputDecoration.collapsed(
|
||||
border: const NoInputBorder(),
|
||||
hintText: batch.formatTip,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/ui/ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import 'form_row.dart';
|
||||
@@ -153,13 +154,13 @@ class _Banner extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
GlyphIcon(glyph, size: 20.ap, color: foregroundColor),
|
||||
GlyphIcon(glyph, size: 20.ap(context), color: foregroundColor),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: foregroundColor,
|
||||
),
|
||||
style: AppTextStyle.subheadline
|
||||
.of(context)
|
||||
.copyWith(color: foregroundColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/ui/ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@@ -48,9 +48,10 @@ class FilterRegexText extends StatelessWidget {
|
||||
regex,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodySmall?.toJetBrainsMono.copyWith(
|
||||
color: context.colorScheme.tertiary,
|
||||
),
|
||||
style: AppTextStyle.footnote
|
||||
.of(context)
|
||||
.toJetBrainsMono
|
||||
.copyWith(color: context.colorScheme.tertiary),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export 'list_editor_page.dart';
|
||||
export 'form_row.dart';
|
||||
export 'form_page.dart';
|
||||
export 'named_url_form.dart';
|
||||
export 'map_edit_view.dart';
|
||||
export 'batch_section.dart';
|
||||
export 'custom_issue.dart';
|
||||
export 'nested_form_sheet.dart';
|
||||
export 'selection_sheet.dart';
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/ui/ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import 'custom_issue.dart';
|
||||
|
||||
/// A form [showFormSheet] opens: grouped rows under a bar whose check saves,
|
||||
/// with why the last save was refused above them.
|
||||
class FormPage extends StatelessWidget {
|
||||
final String title;
|
||||
final VoidCallback onSave;
|
||||
final String? saveLabel;
|
||||
final List<IconButtonData> actions;
|
||||
final String? error;
|
||||
final bool saving;
|
||||
final List<Widget> children;
|
||||
|
||||
const FormPage({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.onSave,
|
||||
this.saveLabel,
|
||||
this.actions = const [],
|
||||
this.error,
|
||||
this.saving = false,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CommonScaffold(
|
||||
title: title,
|
||||
iconActions: actions,
|
||||
confirmationAction: IconButtonData(
|
||||
glyph: AppGlyphs.check,
|
||||
tooltip: saveLabel ?? context.appLocalizations.save,
|
||||
isLoading: saving,
|
||||
onPressed: saving ? null : onSave,
|
||||
),
|
||||
body: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
).copyWith(top: context.contentTopPadding, bottom: 20),
|
||||
children: [
|
||||
ErrorBanner(message: error),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class InfoMessageButton extends StatelessWidget {
|
||||
@@ -20,7 +21,7 @@ class InfoMessageButton extends StatelessWidget {
|
||||
icon: GlyphIcon(
|
||||
AppGlyphs.info,
|
||||
fill: 1,
|
||||
size: 20.ap,
|
||||
size: 20.ap(context),
|
||||
color: context.colorScheme.error,
|
||||
),
|
||||
),
|
||||
@@ -112,7 +113,7 @@ class FormRow extends StatelessWidget {
|
||||
Flexible(
|
||||
child: IconTheme(
|
||||
data: IconThemeData(
|
||||
size: 16.ap,
|
||||
size: 16.ap(context),
|
||||
color: context.colorScheme.onSurface.opacity60,
|
||||
),
|
||||
child: Container(
|
||||
@@ -133,3 +134,164 @@ class FormRow extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A bare text field set against the end of a [FormRow], as iOS and macOS set
|
||||
/// one in a grouped form; the row's focus ring is the only outline it gets.
|
||||
class FormRowTextField extends StatelessWidget {
|
||||
static const _cursorWidth = 2.0;
|
||||
|
||||
// RenderEditable's caret gap plus cursor, which it keeps clear past the text.
|
||||
static const _caretMargin = 1 + _cursorWidth;
|
||||
|
||||
final TextEditingController? controller;
|
||||
final String? initialValue;
|
||||
final FocusNode? focusNode;
|
||||
final String? hintText;
|
||||
final TextInputType? keyboardType;
|
||||
final TextInputAction? textInputAction;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final bool obscureText;
|
||||
final bool autofocus;
|
||||
final bool autocorrect;
|
||||
final bool enableSuggestions;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onFieldSubmitted;
|
||||
|
||||
const FormRowTextField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.initialValue,
|
||||
this.focusNode,
|
||||
this.hintText,
|
||||
this.keyboardType,
|
||||
this.textInputAction,
|
||||
this.inputFormatters,
|
||||
this.obscureText = false,
|
||||
this.autofocus = false,
|
||||
this.autocorrect = true,
|
||||
this.enableSuggestions = true,
|
||||
this.onChanged,
|
||||
this.onFieldSubmitted,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final towardEnd = switch (Directionality.of(context)) {
|
||||
TextDirection.ltr => 1.0,
|
||||
TextDirection.rtl => -1.0,
|
||||
};
|
||||
return InputDecorationTheme(
|
||||
data: InputDecorationTheme.of(
|
||||
context,
|
||||
).copyWith(floatingLabelBehavior: FloatingLabelBehavior.never),
|
||||
child: Transform.translate(
|
||||
offset: Offset(_caretMargin * towardEnd, 0),
|
||||
child: _buildField(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildField(BuildContext context) {
|
||||
final hintText = this.hintText;
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
initialValue: initialValue,
|
||||
focusNode: focusNode,
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
inputFormatters: inputFormatters,
|
||||
obscureText: obscureText,
|
||||
autofocus: autofocus,
|
||||
autocorrect: autocorrect,
|
||||
enableSuggestions: enableSuggestions,
|
||||
onChanged: onChanged,
|
||||
onFieldSubmitted: onFieldSubmitted,
|
||||
textAlign: TextAlign.end,
|
||||
cursorWidth: _cursorWidth,
|
||||
decoration: InputDecoration.collapsed(
|
||||
border: const NoInputBorder(),
|
||||
hintText: null,
|
||||
hint: hintText == null
|
||||
? null
|
||||
: Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: _caretMargin),
|
||||
child: Text(
|
||||
hintText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.end,
|
||||
style: AppTextStyle.body
|
||||
.of(context)
|
||||
.copyWith(color: context.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A [FormRowTextField] for a secret, with the button that shows it.
|
||||
class FormRowSecretTextField extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final FocusNode? focusNode;
|
||||
final String? hintText;
|
||||
final TextInputAction? textInputAction;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onFieldSubmitted;
|
||||
|
||||
const FormRowSecretTextField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.focusNode,
|
||||
this.hintText,
|
||||
this.textInputAction,
|
||||
this.inputFormatters,
|
||||
this.onChanged,
|
||||
this.onFieldSubmitted,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FormRowSecretTextField> createState() => _FormRowSecretTextFieldState();
|
||||
}
|
||||
|
||||
class _FormRowSecretTextFieldState extends State<FormRowSecretTextField> {
|
||||
bool _obscure = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: FormRowTextField(
|
||||
controller: widget.controller,
|
||||
focusNode: widget.focusNode,
|
||||
hintText: widget.hintText,
|
||||
obscureText: _obscure,
|
||||
textInputAction: widget.textInputAction,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
inputFormatters: widget.inputFormatters,
|
||||
onChanged: widget.onChanged,
|
||||
onFieldSubmitted: widget.onFieldSubmitted,
|
||||
),
|
||||
),
|
||||
CommonMinIconButtonTheme(
|
||||
child: IconButton(
|
||||
tooltip: _obscure
|
||||
? appLocalizations.showPassword
|
||||
: appLocalizations.hidePassword,
|
||||
icon: GlyphIcon(_obscure ? AppGlyphs.eye : AppGlyphs.eyeOff),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscure = !_obscure;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/models/common.dart';
|
||||
import 'package:fl_clash/models/state.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
@@ -29,7 +26,6 @@ class ListEditorPage<T, K> extends ConsumerStatefulWidget {
|
||||
final List<CommonPopupMenuItem> addMenuItems;
|
||||
final String emptyLabel;
|
||||
final double? itemExtent;
|
||||
final bool selectionEnabled;
|
||||
final K Function(T item) idOf;
|
||||
final void Function(Set<K> ids)? onDelete;
|
||||
final Iterable<String?> Function(T item)? searchFieldsOf;
|
||||
@@ -46,7 +42,6 @@ class ListEditorPage<T, K> extends ConsumerStatefulWidget {
|
||||
this.addMenuItems = const [],
|
||||
required this.emptyLabel,
|
||||
this.itemExtent,
|
||||
this.selectionEnabled = false,
|
||||
required this.idOf,
|
||||
this.onDelete,
|
||||
this.searchFieldsOf,
|
||||
@@ -77,7 +72,7 @@ class _ListEditorPageState<T, K> extends ConsumerState<ListEditorPage<T, K>> {
|
||||
}
|
||||
|
||||
Set<K> _liveSelection(List<T>? items) {
|
||||
if (!widget.selectionEnabled || _selected.isEmpty || items == null) {
|
||||
if (_selected.isEmpty || items == null) {
|
||||
return const {};
|
||||
}
|
||||
return items.map(widget.idOf).where(_selected.contains).toSet();
|
||||
@@ -100,9 +95,6 @@ class _ListEditorPageState<T, K> extends ConsumerState<ListEditorPage<T, K>> {
|
||||
}
|
||||
|
||||
void _handleToggleSelected(T item) {
|
||||
if (!widget.selectionEnabled) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_selected = {..._selected}..addOrRemove(widget.idOf(item));
|
||||
});
|
||||
@@ -124,6 +116,8 @@ class _ListEditorPageState<T, K> extends ConsumerState<ListEditorPage<T, K>> {
|
||||
message: TextSpan(
|
||||
text: context.appLocalizations.deleteMultipTip(widget.title),
|
||||
),
|
||||
destructive: true,
|
||||
confirmText: context.appLocalizations.delete,
|
||||
);
|
||||
if (res != true || !mounted) {
|
||||
return;
|
||||
@@ -358,8 +352,7 @@ class ListEditorAddAction extends StatelessWidget {
|
||||
targetBuilder: (open) => AppBarActionButton(
|
||||
data: IconButtonData(
|
||||
glyph: AppGlyphs.addCircle,
|
||||
onPressed: () =>
|
||||
open(offset: Offset(0, context.isMobileView ? 0 : 20)),
|
||||
onPressed: open,
|
||||
tooltip: appLocalizations.add,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'batch_section.dart';
|
||||
import 'form_page.dart';
|
||||
import 'form_row.dart';
|
||||
|
||||
typedef _Entry = MapEntry<String, String>;
|
||||
|
||||
class MapEditView extends ConsumerStatefulWidget {
|
||||
final String title;
|
||||
final Map<String, String> entries;
|
||||
final Widget Function(MapEntry<String, String> item) titleBuilder;
|
||||
final Widget Function(MapEntry<String, String> item)? subtitleBuilder;
|
||||
final int? keyMaxLength;
|
||||
final int? valueMaxLength;
|
||||
|
||||
const MapEditView({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.entries,
|
||||
required this.titleBuilder,
|
||||
this.subtitleBuilder,
|
||||
this.keyMaxLength,
|
||||
this.valueMaxLength,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<MapEditView> createState() => _MapEditViewState();
|
||||
}
|
||||
|
||||
class _MapEditViewState extends EditViewState<MapEditView, _Entry> {
|
||||
@override
|
||||
String get title => widget.title;
|
||||
|
||||
@override
|
||||
List<_Entry> get initialEntries => widget.entries.entries.toList();
|
||||
|
||||
@override
|
||||
String idOf(_Entry entry) => entry.key;
|
||||
|
||||
@override
|
||||
Widget buildTitle(_Entry entry) => widget.titleBuilder(entry);
|
||||
|
||||
@override
|
||||
Widget? buildSubtitle(_Entry entry) => widget.subtitleBuilder?.call(entry);
|
||||
|
||||
@override
|
||||
bool sameEntries(List<_Entry> a, List<_Entry> b) =>
|
||||
stringAndStringMapEntryListEquality.equals(a, b);
|
||||
|
||||
@override
|
||||
Object popResult(List<_Entry> entries) =>
|
||||
Map<String, String>.fromEntries(entries);
|
||||
|
||||
@override
|
||||
Future<List<_Entry>?> showEntryEditor(_Entry? entry) {
|
||||
return showFormSheet<List<_Entry>>(
|
||||
context: context,
|
||||
builder: (_) => _MapEntryForm(
|
||||
listTitle: widget.title,
|
||||
entry: entry,
|
||||
existingKeys: entries.map(idOf).toSet(),
|
||||
keyMaxLength: widget.keyMaxLength,
|
||||
valueMaxLength: widget.valueMaxLength,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _MapEntryField { key, value }
|
||||
|
||||
/// Adds when [entry] is null, and then a batch may add several entries.
|
||||
class _MapEntryForm extends StatefulWidget {
|
||||
final String listTitle;
|
||||
final _Entry? entry;
|
||||
final Set<String> existingKeys;
|
||||
final int? keyMaxLength;
|
||||
final int? valueMaxLength;
|
||||
|
||||
const _MapEntryForm({
|
||||
required this.listTitle,
|
||||
required this.entry,
|
||||
required this.existingKeys,
|
||||
required this.keyMaxLength,
|
||||
required this.valueMaxLength,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_MapEntryForm> createState() => _MapEntryFormState();
|
||||
}
|
||||
|
||||
class _MapEntryFormState extends State<_MapEntryForm> {
|
||||
final _valueFocusNode = FocusNode();
|
||||
late final TextEditingController _keyController;
|
||||
late final TextEditingController _valueController;
|
||||
final _batchController = TextEditingController();
|
||||
bool _isBatch = false;
|
||||
bool _attempted = false;
|
||||
ParsedInput<_Entry>? _parsed;
|
||||
|
||||
String get _keyLabel => context.appLocalizations.key;
|
||||
|
||||
String get _valueLabel => context.appLocalizations.value;
|
||||
|
||||
BatchInput<_Entry> get _batchInput {
|
||||
return BatchInput(
|
||||
label: widget.listTitle,
|
||||
formatTip: context.appLocalizations.batchMapInputTip,
|
||||
parse: (text) => parseMapInput(
|
||||
text,
|
||||
existingKeys: widget.existingKeys,
|
||||
keyMaxLength: widget.keyMaxLength,
|
||||
valueMaxLength: widget.valueMaxLength,
|
||||
),
|
||||
issueMessage: _batchIssueMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_keyController = TextEditingController(text: widget.entry?.key);
|
||||
_valueController = TextEditingController(text: widget.entry?.value);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_valueFocusNode.dispose();
|
||||
_keyController.dispose();
|
||||
_valueController.dispose();
|
||||
_batchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _batchIssueMessage(InputIssue issue) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return switch (issue.kind) {
|
||||
InputIssueKind.keyTooLong => appLocalizations.maxLengthTip(
|
||||
_keyLabel,
|
||||
widget.keyMaxLength!,
|
||||
),
|
||||
InputIssueKind.valueTooLong => appLocalizations.maxLengthTip(
|
||||
_valueLabel,
|
||||
widget.valueMaxLength!,
|
||||
),
|
||||
InputIssueKind.missingValue => appLocalizations.emptyTip(_valueLabel),
|
||||
InputIssueKind.invalidUrl ||
|
||||
InputIssueKind.invalidValue => appLocalizations.urlTip(_valueLabel),
|
||||
};
|
||||
}
|
||||
|
||||
({_MapEntryField field, String message})? _issue() {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final key = _keyController.text.trim();
|
||||
if (key.isEmpty) {
|
||||
return (
|
||||
field: _MapEntryField.key,
|
||||
message: appLocalizations.emptyTip(_keyLabel),
|
||||
);
|
||||
}
|
||||
if (key != widget.entry?.key && widget.existingKeys.contains(key)) {
|
||||
return (
|
||||
field: _MapEntryField.key,
|
||||
message: appLocalizations.existsTip(_keyLabel),
|
||||
);
|
||||
}
|
||||
if (_valueController.text.trim().isEmpty) {
|
||||
return (
|
||||
field: _MapEntryField.value,
|
||||
message: appLocalizations.emptyTip(_valueLabel),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _toggleBatch() {
|
||||
setState(() {
|
||||
_isBatch = !_isBatch;
|
||||
_attempted = false;
|
||||
if (_isBatch && _batchController.text.isEmpty) {
|
||||
_batchController.text = [
|
||||
_keyController.text,
|
||||
_valueController.text,
|
||||
].map((text) => text.trim()).where((text) => text.isNotEmpty).join(' ');
|
||||
}
|
||||
if (_isBatch) {
|
||||
_parsed = _batchInput.parse(_batchController.text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleChanged(String _) {
|
||||
if (_attempted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleBatchChanged(String text) {
|
||||
setState(() {
|
||||
_parsed = _batchInput.parse(text);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleSubmit() {
|
||||
if (_isBatch) {
|
||||
final parsed = _parsed;
|
||||
if (parsed == null || !parsed.isValid || parsed.entries.isEmpty) {
|
||||
setState(() {
|
||||
_attempted = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop<List<_Entry>>(parsed.entries);
|
||||
return;
|
||||
}
|
||||
if (_issue() != null) {
|
||||
setState(() {
|
||||
_attempted = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop<List<_Entry>>([
|
||||
MapEntry(_keyController.text.trim(), _valueController.text.trim()),
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final issue = _attempted && !_isBatch ? _issue() : null;
|
||||
return FormPage(
|
||||
title: _isBatch
|
||||
? appLocalizations.batchAdd
|
||||
: widget.entry == null
|
||||
? appLocalizations.add
|
||||
: appLocalizations.edit,
|
||||
saveLabel: appLocalizations.confirm,
|
||||
onSave: _handleSubmit,
|
||||
error: _isBatch
|
||||
? _batchInput.errorOf(
|
||||
appLocalizations,
|
||||
_parsed,
|
||||
attempted: _attempted,
|
||||
)
|
||||
: issue?.message,
|
||||
actions: [
|
||||
if (widget.entry == null)
|
||||
IconButtonData(
|
||||
glyph: _isBatch ? AppGlyphs.textShort : AppGlyphs.listAdd,
|
||||
tooltip: _isBatch
|
||||
? appLocalizations.singleAdd
|
||||
: appLocalizations.batchAdd,
|
||||
onPressed: _toggleBatch,
|
||||
),
|
||||
],
|
||||
children: [
|
||||
_isBatch
|
||||
? BatchSection(
|
||||
batch: _batchInput,
|
||||
controller: _batchController,
|
||||
parsed: _parsed,
|
||||
onChanged: _handleBatchChanged,
|
||||
)
|
||||
: _buildFields(appLocalizations, issue?.field),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFields(
|
||||
AppLocalizations appLocalizations,
|
||||
_MapEntryField? invalid,
|
||||
) {
|
||||
final keyMaxLength = widget.keyMaxLength;
|
||||
final valueMaxLength = widget.valueMaxLength;
|
||||
return generateSection(
|
||||
items: [
|
||||
FormRow(
|
||||
title: _keyLabel,
|
||||
invalid: invalid == _MapEntryField.key,
|
||||
trailing: FormRowTextField(
|
||||
controller: _keyController,
|
||||
autofocus: true,
|
||||
hintText: appLocalizations.required,
|
||||
textInputAction: TextInputAction.next,
|
||||
autocorrect: false,
|
||||
inputFormatters: keyMaxLength == null
|
||||
? null
|
||||
: TextInputLimits.limit(keyMaxLength),
|
||||
onChanged: _handleChanged,
|
||||
onFieldSubmitted: (_) => _valueFocusNode.requestFocus(),
|
||||
),
|
||||
),
|
||||
FormRow(
|
||||
title: _valueLabel,
|
||||
invalid: invalid == _MapEntryField.value,
|
||||
trailing: FormRowTextField(
|
||||
controller: _valueController,
|
||||
focusNode: _valueFocusNode,
|
||||
hintText: appLocalizations.required,
|
||||
textInputAction: TextInputAction.done,
|
||||
autocorrect: false,
|
||||
inputFormatters: valueMaxLength == null
|
||||
? null
|
||||
: TextInputLimits.limit(valueMaxLength),
|
||||
onChanged: _handleChanged,
|
||||
onFieldSubmitted: (_) => _handleSubmit(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import 'batch_section.dart';
|
||||
import 'form_page.dart';
|
||||
import 'form_row.dart';
|
||||
|
||||
typedef NamedUrl = ({String label, String url, String ageSecretKey});
|
||||
|
||||
Future<NamedUrl?> showNamedUrlForm(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
String label = '',
|
||||
String url = '',
|
||||
FormFieldValidator<String>? labelValidator,
|
||||
FormFieldValidator<String>? urlValidator,
|
||||
}) async {
|
||||
final res = await showFormSheet<List<NamedUrl>>(
|
||||
context: context,
|
||||
builder: (_) => NamedUrlForm(
|
||||
title: title,
|
||||
label: label,
|
||||
url: url,
|
||||
labelValidator: labelValidator,
|
||||
urlValidator: urlValidator,
|
||||
),
|
||||
);
|
||||
return res?.single;
|
||||
}
|
||||
|
||||
/// A batch skips the urls in [existingUrls].
|
||||
Future<List<NamedUrl>?> showNamedUrlsForm(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
String? urlLabel,
|
||||
String? batchTip,
|
||||
FormFieldValidator<String>? labelValidator,
|
||||
FormFieldValidator<String>? urlValidator,
|
||||
Set<String> existingUrls = const {},
|
||||
bool withAgeSecretKey = false,
|
||||
}) {
|
||||
return showFormSheet<List<NamedUrl>>(
|
||||
context: context,
|
||||
builder: (_) => NamedUrlForm(
|
||||
title: title,
|
||||
urlLabel: urlLabel,
|
||||
batchTip: batchTip,
|
||||
labelValidator: labelValidator,
|
||||
urlValidator: urlValidator,
|
||||
batch: true,
|
||||
existingUrls: existingUrls,
|
||||
withAgeSecretKey: withAgeSecretKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
enum _NamedUrlField { label, url }
|
||||
|
||||
/// The url below the name holds the focus, as the one field that must be filled.
|
||||
/// Pops a list so a single url and a batch return through the same path.
|
||||
class NamedUrlForm extends StatefulWidget {
|
||||
final String title;
|
||||
final String label;
|
||||
final String url;
|
||||
final String? urlLabel;
|
||||
final String? batchTip;
|
||||
final FormFieldValidator<String>? labelValidator;
|
||||
final FormFieldValidator<String>? urlValidator;
|
||||
final bool batch;
|
||||
final Set<String> existingUrls;
|
||||
final bool withAgeSecretKey;
|
||||
|
||||
const NamedUrlForm({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.label = '',
|
||||
this.url = '',
|
||||
this.urlLabel,
|
||||
this.batchTip,
|
||||
this.labelValidator,
|
||||
this.urlValidator,
|
||||
this.batch = false,
|
||||
this.existingUrls = const {},
|
||||
this.withAgeSecretKey = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<NamedUrlForm> createState() => _NamedUrlFormState();
|
||||
}
|
||||
|
||||
class _NamedUrlFormState extends State<NamedUrlForm> {
|
||||
final _urlFocusNode = FocusNode();
|
||||
final _ageSecretKeyFocusNode = FocusNode();
|
||||
late final TextEditingController _labelController;
|
||||
late final TextEditingController _urlController;
|
||||
final _ageSecretKeyController = TextEditingController();
|
||||
final _batchController = TextEditingController();
|
||||
bool _isBatch = false;
|
||||
bool _attempted = false;
|
||||
ParsedInput<String>? _parsed;
|
||||
|
||||
String get _urlLabel => widget.urlLabel ?? context.appLocalizations.url;
|
||||
|
||||
BatchInput<String> get _batchInput {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return BatchInput(
|
||||
label: _urlLabel,
|
||||
formatTip: widget.batchTip ?? appLocalizations.batchUrlInputTip,
|
||||
parse: (text) => parseUrlInput(
|
||||
text,
|
||||
isValid: (url) => _validateBatchUrl(url) == null,
|
||||
existing: widget.existingUrls,
|
||||
),
|
||||
issueMessage: (issue) => _validateBatchUrl(issue.raw)!,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_labelController = TextEditingController(text: widget.label);
|
||||
_urlController = TextEditingController(text: widget.url);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlFocusNode.dispose();
|
||||
_ageSecretKeyFocusNode.dispose();
|
||||
_labelController.dispose();
|
||||
_urlController.dispose();
|
||||
_ageSecretKeyController.dispose();
|
||||
_batchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? _validateUrl(String? value) {
|
||||
final validator = widget.urlValidator;
|
||||
if (validator != null) {
|
||||
return validator(value);
|
||||
}
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final url = value?.trim() ?? '';
|
||||
if (url.isEmpty) {
|
||||
return appLocalizations.emptyTip(_urlLabel);
|
||||
}
|
||||
if (!url.isUrl) {
|
||||
return appLocalizations.urlTip(_urlLabel);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateBatchUrl(String url) {
|
||||
if (url.length > TextInputLimits.url) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return appLocalizations.maxLengthTip(_urlLabel, TextInputLimits.url);
|
||||
}
|
||||
return _validateUrl(url);
|
||||
}
|
||||
|
||||
({_NamedUrlField field, String message})? _issue() {
|
||||
final labelError = widget.labelValidator?.call(_labelController.text);
|
||||
if (labelError != null) {
|
||||
return (field: _NamedUrlField.label, message: labelError);
|
||||
}
|
||||
final urlError = _validateUrl(_urlController.text);
|
||||
if (urlError != null) {
|
||||
return (field: _NamedUrlField.url, message: urlError);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _toggleBatch() {
|
||||
setState(() {
|
||||
_isBatch = !_isBatch;
|
||||
_attempted = false;
|
||||
if (_isBatch && _batchController.text.isEmpty) {
|
||||
_batchController.text = _urlController.text.trim();
|
||||
}
|
||||
if (_isBatch) {
|
||||
_parsed = _batchInput.parse(_batchController.text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleChanged(String _) {
|
||||
if (_attempted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleBatchChanged(String text) {
|
||||
setState(() {
|
||||
_parsed = _batchInput.parse(text);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleSubmit() {
|
||||
if (_isBatch) {
|
||||
final parsed = _parsed;
|
||||
if (parsed == null || !parsed.isValid || parsed.entries.isEmpty) {
|
||||
setState(() {
|
||||
_attempted = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop<List<NamedUrl>>([
|
||||
for (final url in parsed.entries)
|
||||
(label: '', url: url, ageSecretKey: ''),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (_issue() != null) {
|
||||
setState(() {
|
||||
_attempted = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop<List<NamedUrl>>([
|
||||
(
|
||||
label: _labelController.text.trim(),
|
||||
url: _urlController.text.trim(),
|
||||
ageSecretKey: _ageSecretKeyController.text.trim(),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final issue = _attempted && !_isBatch ? _issue() : null;
|
||||
return FormPage(
|
||||
title: _isBatch ? appLocalizations.batchImport : widget.title,
|
||||
saveLabel: appLocalizations.submit,
|
||||
onSave: _handleSubmit,
|
||||
error: _isBatch
|
||||
? _batchInput.errorOf(
|
||||
appLocalizations,
|
||||
_parsed,
|
||||
attempted: _attempted,
|
||||
)
|
||||
: issue?.message,
|
||||
actions: [
|
||||
if (widget.batch)
|
||||
IconButtonData(
|
||||
glyph: _isBatch ? AppGlyphs.textShort : AppGlyphs.listAdd,
|
||||
tooltip: _isBatch
|
||||
? appLocalizations.singleImport
|
||||
: appLocalizations.batchImport,
|
||||
onPressed: _toggleBatch,
|
||||
),
|
||||
],
|
||||
children: [
|
||||
_isBatch
|
||||
? BatchSection(
|
||||
batch: _batchInput,
|
||||
controller: _batchController,
|
||||
parsed: _parsed,
|
||||
onChanged: _handleBatchChanged,
|
||||
)
|
||||
: _buildFields(appLocalizations, issue?.field),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFields(
|
||||
AppLocalizations appLocalizations,
|
||||
_NamedUrlField? invalid,
|
||||
) {
|
||||
return generateSection(
|
||||
items: [
|
||||
FormRow(
|
||||
title: appLocalizations.name,
|
||||
invalid: invalid == _NamedUrlField.label,
|
||||
trailing: FormRowTextField(
|
||||
controller: _labelController,
|
||||
hintText: appLocalizations.optional,
|
||||
textInputAction: TextInputAction.next,
|
||||
inputFormatters: TextInputLimits.limit(TextInputLimits.name),
|
||||
onChanged: _handleChanged,
|
||||
onFieldSubmitted: (_) => _urlFocusNode.requestFocus(),
|
||||
),
|
||||
),
|
||||
FormRow(
|
||||
title: _urlLabel,
|
||||
invalid: invalid == _NamedUrlField.url,
|
||||
trailing: FormRowTextField(
|
||||
controller: _urlController,
|
||||
focusNode: _urlFocusNode,
|
||||
autofocus: true,
|
||||
hintText: appLocalizations.required,
|
||||
keyboardType: TextInputType.url,
|
||||
textInputAction: widget.withAgeSecretKey
|
||||
? TextInputAction.next
|
||||
: TextInputAction.done,
|
||||
autocorrect: false,
|
||||
inputFormatters: TextInputLimits.limit(TextInputLimits.url),
|
||||
onChanged: _handleChanged,
|
||||
onFieldSubmitted: (_) {
|
||||
if (widget.withAgeSecretKey) {
|
||||
_ageSecretKeyFocusNode.requestFocus();
|
||||
} else {
|
||||
_handleSubmit();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
if (widget.withAgeSecretKey)
|
||||
FormRow(
|
||||
title: appLocalizations.ageSecretKey,
|
||||
trailing: FormRowSecretTextField(
|
||||
controller: _ageSecretKeyController,
|
||||
focusNode: _ageSecretKeyFocusNode,
|
||||
hintText: appLocalizations.optional,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _handleSubmit(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ Future<void> showNestedFormSheet<T>({
|
||||
required T Function(WidgetRef ref) currentOf,
|
||||
required WidgetBuilder formBuilder,
|
||||
}) {
|
||||
return showSheet(
|
||||
return showFormSheet(
|
||||
context: context,
|
||||
props: nestedPagedSheetProps,
|
||||
builder: (_) {
|
||||
|
||||
+158
-261
@@ -1,14 +1,16 @@
|
||||
library;
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/models/clash_config.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import 'form_page.dart';
|
||||
import 'form_row.dart';
|
||||
|
||||
const _ruleRowVerticalPadding = 7.0;
|
||||
|
||||
/// Rule lists run to thousands of rows, so a rule row is padded tighter than
|
||||
@@ -88,7 +90,7 @@ class RuleItem extends StatelessWidget {
|
||||
icon: GlyphIcon(
|
||||
AppGlyphs.info,
|
||||
fill: 1,
|
||||
size: 16.ap,
|
||||
size: 16.ap(context),
|
||||
color: context.colorScheme.error,
|
||||
),
|
||||
),
|
||||
@@ -150,10 +152,9 @@ class RuleItem extends StatelessWidget {
|
||||
: rule.realTarget!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.toJetBrainsMono
|
||||
style: AppTextStyle.subheadline
|
||||
.of(context)
|
||||
.toJetBrainsMono
|
||||
.copyWith(color: checkResult.color),
|
||||
),
|
||||
),
|
||||
@@ -200,7 +201,7 @@ class _RuleItemLabel extends StatelessWidget {
|
||||
content,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.listSubtitleStyle?.toJetBrainsMono.copyWith(
|
||||
style: context.listSubtitleStyle.toJetBrainsMono.copyWith(
|
||||
color: style.color?.opacity60,
|
||||
),
|
||||
),
|
||||
@@ -231,7 +232,7 @@ class RuleStatusItem extends StatelessWidget {
|
||||
rule.rawValue,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyMedium?.toJetBrainsMono,
|
||||
style: AppTextStyle.subheadline.of(context).toJetBrainsMono,
|
||||
),
|
||||
),
|
||||
trailing: Switch(value: status, onChanged: onChange),
|
||||
@@ -242,291 +243,187 @@ class RuleStatusItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class AddOrEditRuleDialog extends StatefulWidget {
|
||||
enum _RuleField { content, target }
|
||||
|
||||
class RuleForm extends StatefulWidget {
|
||||
final Rule? rule;
|
||||
|
||||
const AddOrEditRuleDialog({super.key, this.rule});
|
||||
const RuleForm({super.key, this.rule});
|
||||
|
||||
@override
|
||||
State<AddOrEditRuleDialog> createState() => _AddOrEditRuleDialogState();
|
||||
State<RuleForm> createState() => _RuleFormState();
|
||||
}
|
||||
|
||||
class _AddOrEditRuleDialogState extends State<AddOrEditRuleDialog> {
|
||||
class _RuleFormState extends State<RuleForm> {
|
||||
late RuleAction _ruleAction;
|
||||
String _ruleTarget = '';
|
||||
final _ruleTargetController = TextEditingController();
|
||||
final _contentController = TextEditingController();
|
||||
bool _noResolve = false;
|
||||
bool _src = false;
|
||||
List<DropdownMenuEntry> _targetItems = [];
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
bool _initialized = false;
|
||||
late String _ruleTarget;
|
||||
late final TextEditingController _contentController;
|
||||
late bool _noResolve;
|
||||
late bool _src;
|
||||
bool _attempted = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_initialized) {
|
||||
return;
|
||||
}
|
||||
_initialized = true;
|
||||
_initState();
|
||||
}
|
||||
|
||||
void _initState() {
|
||||
_targetItems = [
|
||||
...RuleTarget.ruleTargetNames.map(
|
||||
(item) => DropdownMenuEntry(value: item, label: item),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: RuleAction.MATCH.value,
|
||||
label: context.appLocalizations.matchTarget,
|
||||
),
|
||||
];
|
||||
void initState() {
|
||||
super.initState();
|
||||
final rule = widget.rule;
|
||||
if (rule != null) {
|
||||
_ruleAction = rule.ruleAction;
|
||||
_contentController.text = rule.content ?? '';
|
||||
_selectTarget(rule.ruleTarget ?? '');
|
||||
_noResolve = rule.noResolve;
|
||||
_src = rule.src;
|
||||
return;
|
||||
}
|
||||
_ruleAction = RuleAction.addedRuleActions.first;
|
||||
if (_targetItems.isNotEmpty) {
|
||||
_selectTarget(_targetItems.first.value);
|
||||
}
|
||||
}
|
||||
|
||||
void _selectTarget(String value) {
|
||||
_ruleTarget = value;
|
||||
_ruleTargetController.text =
|
||||
_targetItems.firstWhereOrNull((item) => item.value == value)?.label ??
|
||||
value;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AddOrEditRuleDialog oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.rule != widget.rule) {
|
||||
_initState();
|
||||
}
|
||||
_ruleAction = rule?.ruleAction ?? RuleAction.addedRuleActions.first;
|
||||
_ruleTarget = rule?.ruleTarget ?? RuleTarget.ruleTargetNames.first;
|
||||
_contentController = TextEditingController(text: rule?.content);
|
||||
_noResolve = rule?.noResolve ?? false;
|
||||
_src = rule?.src ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ruleTargetController.dispose();
|
||||
_contentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
({_RuleField field, String message})? _issue() {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final content = _contentController.text;
|
||||
if (content.isEmpty) {
|
||||
return (
|
||||
field: _RuleField.content,
|
||||
message: appLocalizations.emptyTip(appLocalizations.content),
|
||||
);
|
||||
}
|
||||
final payloadError = Rule(
|
||||
ruleAction: _ruleAction,
|
||||
content: content,
|
||||
).payloadError;
|
||||
if (payloadError != null) {
|
||||
return (
|
||||
field: _RuleField.content,
|
||||
message: payloadError.getMessage(context),
|
||||
);
|
||||
}
|
||||
if (_ruleTarget.isEmpty) {
|
||||
return (
|
||||
field: _RuleField.target,
|
||||
message: appLocalizations.splitStrategyNotEmpty,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _handleSubmit() {
|
||||
final res = _formKey.currentState?.validate();
|
||||
if (res == false) {
|
||||
if (_issue() != null) {
|
||||
setState(() {
|
||||
_attempted = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final rule = Rule(
|
||||
id: widget.rule?.id ?? snowflake.id,
|
||||
ruleAction: _ruleAction,
|
||||
content: _contentController.text,
|
||||
ruleTarget: _ruleTarget,
|
||||
noResolve: _noResolve,
|
||||
src: _src,
|
||||
Navigator.of(context).pop(
|
||||
(widget.rule ?? Rule(id: snowflake.id)).copyWith(
|
||||
ruleAction: _ruleAction,
|
||||
content: _contentController.text,
|
||||
ruleTarget: _ruleTarget,
|
||||
noResolve: _noResolve,
|
||||
src: _src,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _targetLabel(String target) => target == RuleAction.MATCH.value
|
||||
? context.appLocalizations.matchTarget
|
||||
: target;
|
||||
|
||||
Widget _buildSwitchRow({
|
||||
required String title,
|
||||
required bool value,
|
||||
required ValueChanged<bool> onChanged,
|
||||
}) {
|
||||
return FormRow(
|
||||
title: title,
|
||||
onPressed: () => onChanged(!value),
|
||||
trailing: Switch(value: value, onChanged: onChanged),
|
||||
);
|
||||
Navigator.of(context).pop(rule);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return CommonDialog(
|
||||
final issue = _attempted ? _issue() : null;
|
||||
final targets = [...RuleTarget.ruleTargetNames, RuleAction.MATCH.value];
|
||||
return FormPage(
|
||||
title: widget.rule != null
|
||||
? appLocalizations.editRule
|
||||
: appLocalizations.addRule,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _handleSubmit,
|
||||
child: Text(appLocalizations.confirm),
|
||||
),
|
||||
],
|
||||
child: DropdownMenuTheme(
|
||||
data: DropdownMenuThemeData(
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
border: AppShape.input,
|
||||
labelStyle: context.textTheme.bodyLarge?.copyWith(
|
||||
overflow: TextOverflow.ellipsis,
|
||||
onSave: _handleSubmit,
|
||||
error: issue?.message,
|
||||
children: [
|
||||
generateSection(
|
||||
title: appLocalizations.basicInfo,
|
||||
items: [
|
||||
ListItem<RuleAction>.options(
|
||||
title: Text(appLocalizations.proxyType),
|
||||
options: RuleAction.addedRuleActions,
|
||||
value: _ruleAction,
|
||||
textBuilder: (action) => action.value,
|
||||
onChanged: (action) {
|
||||
setState(() {
|
||||
_ruleAction = action;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
FormRow(
|
||||
title: appLocalizations.content,
|
||||
invalid: issue?.field == _RuleField.content,
|
||||
trailing: FormRowTextField(
|
||||
controller: _contentController,
|
||||
hintText: appLocalizations.inputRuleContent,
|
||||
autocorrect: false,
|
||||
inputFormatters: TextInputLimits.limit(TextInputLimits.rule),
|
||||
onChanged: (_) {
|
||||
if (_attempted) {
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
onFieldSubmitted: (_) => _handleSubmit(),
|
||||
),
|
||||
),
|
||||
ListItem<String>.options(
|
||||
title: Text(appLocalizations.splitStrategy),
|
||||
options: [
|
||||
if (!targets.contains(_ruleTarget)) _ruleTarget,
|
||||
...targets,
|
||||
],
|
||||
value: _ruleTarget,
|
||||
textBuilder: _targetLabel,
|
||||
onChanged: (target) {
|
||||
setState(() {
|
||||
_ruleTarget = target;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: LayoutBuilder(
|
||||
builder: (_, constraints) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ElasticButton(
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () async {
|
||||
_ruleAction =
|
||||
await dialogs.showCommonDialog<RuleAction>(
|
||||
context: context,
|
||||
child: OptionsDialog<RuleAction>(
|
||||
title: appLocalizations.ruleName,
|
||||
options: RuleAction.addedRuleActions,
|
||||
textBuilder: (item) => item.value,
|
||||
value: _ruleAction,
|
||||
),
|
||||
) ??
|
||||
_ruleAction;
|
||||
setState(() {});
|
||||
},
|
||||
child: Text(_ruleAction.value),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_RuleContentField(
|
||||
controller: _contentController,
|
||||
onSubmitted: _handleSubmit,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_RuleTargetField(
|
||||
controller: _ruleTargetController,
|
||||
entries: _targetItems,
|
||||
onSelected: (value) {
|
||||
if (value != null) {
|
||||
_selectTarget(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (_ruleAction.hasParams) ...[
|
||||
const SizedBox(height: 20),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
_RuleFlagChip(
|
||||
label: appLocalizations.sourceIp,
|
||||
isSelected: _src,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_src = !_src;
|
||||
});
|
||||
},
|
||||
),
|
||||
_RuleFlagChip(
|
||||
label: appLocalizations.noResolve,
|
||||
isSelected: _noResolve,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_noResolve = !_noResolve;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
);
|
||||
},
|
||||
if (_ruleAction.hasParams)
|
||||
generateSection(
|
||||
title: appLocalizations.additionalParameters,
|
||||
items: [
|
||||
_buildSwitchRow(
|
||||
title: appLocalizations.noResolveHostname,
|
||||
value: _noResolve,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_noResolve = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
_buildSwitchRow(
|
||||
title: appLocalizations.matchSourceIp,
|
||||
value: _src,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_src = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RuleContentField extends StatelessWidget {
|
||||
const _RuleContentField({
|
||||
required this.controller,
|
||||
required this.onSubmitted,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final VoidCallback onSubmitted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return TextFormField(
|
||||
keyboardType: TextInputType.text,
|
||||
inputFormatters: TextInputLimits.limit(TextInputLimits.rule),
|
||||
onFieldSubmitted: (_) {
|
||||
onSubmitted();
|
||||
},
|
||||
controller: controller,
|
||||
decoration: InputDecoration(labelText: appLocalizations.content),
|
||||
validator: (_) {
|
||||
if (controller.text.isEmpty) {
|
||||
return appLocalizations.emptyTip(appLocalizations.content);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RuleTargetField extends StatelessWidget {
|
||||
const _RuleTargetField({
|
||||
required this.controller,
|
||||
required this.entries,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final List<DropdownMenuEntry> entries;
|
||||
final ValueChanged<String?> onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return FormField<String>(
|
||||
validator: (_) {
|
||||
if (controller.text.isEmpty) {
|
||||
return appLocalizations.emptyTip(appLocalizations.ruleTarget);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
builder: (filed) {
|
||||
return DropdownMenu(
|
||||
controller: controller,
|
||||
label: Text(appLocalizations.ruleTarget),
|
||||
width: 200,
|
||||
menuHeight: 250,
|
||||
enableFilter: false,
|
||||
enableSearch: false,
|
||||
trailingIcon: const GlyphIcon(AppGlyphs.chevronDown, size: 20),
|
||||
selectedTrailingIcon: const GlyphIcon(AppGlyphs.chevronUp, size: 20),
|
||||
dropdownMenuEntries: entries,
|
||||
onSelected: (value) => onSelected(value as String?),
|
||||
errorText: filed.errorText,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RuleFlagChip extends StatelessWidget {
|
||||
const _RuleFlagChip({
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CommonCard(
|
||||
radius: AppCorner.sm,
|
||||
isSelected: isSelected,
|
||||
onPressed: onPressed,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Text(label, style: context.textTheme.bodyMedium),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:fl_clash/models/clash_config.dart';
|
||||
import 'package:fl_clash/models/common.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
@@ -89,14 +87,13 @@ class _RulePresetSheetState extends State<RulePresetSheet> {
|
||||
const presets = RulePreset.values;
|
||||
return CommonScaffold(
|
||||
title: appLocalizations.quickActions,
|
||||
iconActions: [
|
||||
if (_selected.isNotEmpty)
|
||||
IconButtonData(
|
||||
glyph: AppGlyphs.check,
|
||||
onPressed: _handleConfirm,
|
||||
tooltip: appLocalizations.confirm,
|
||||
),
|
||||
],
|
||||
confirmationAction: _selected.isEmpty
|
||||
? null
|
||||
: IconButtonData(
|
||||
glyph: AppGlyphs.check,
|
||||
onPressed: _handleConfirm,
|
||||
tooltip: appLocalizations.confirm,
|
||||
),
|
||||
body: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
@@ -112,7 +109,7 @@ class _RulePresetSheetState extends State<RulePresetSheet> {
|
||||
title: Text(preset.label(appLocalizations)),
|
||||
subtitle: _PresetSubtitle(preset),
|
||||
isSelected: isSelected,
|
||||
trailing: isSelected ? const GlyphIcon(AppGlyphs.check) : null,
|
||||
trailing: isSelected ? const SelectionCheck() : null,
|
||||
onPressed: () => _handleToggle(preset),
|
||||
),
|
||||
);
|
||||
@@ -137,9 +134,10 @@ class _PresetSubtitle extends StatelessWidget {
|
||||
raw,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodySmall?.toJetBrainsMono.copyWith(
|
||||
color: context.colorScheme.tertiary,
|
||||
),
|
||||
style: AppTextStyle.footnote
|
||||
.of(context)
|
||||
.toJetBrainsMono
|
||||
.copyWith(color: context.colorScheme.tertiary),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
@@ -212,7 +209,10 @@ class _SelectionSheetState<T> extends ConsumerState<SelectionSheet<T>>
|
||||
Widget _buildHeader(SelectionSection<T> section) {
|
||||
final header = Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: ListHeader(title: section.label!, padding: baseInfoEdgeInsets),
|
||||
child: ListHeader(
|
||||
title: section.label!,
|
||||
padding: baseInfoEdgeInsetsOf(context),
|
||||
),
|
||||
);
|
||||
if (!widget.removeOnSelect) {
|
||||
return header;
|
||||
@@ -252,7 +252,7 @@ class _SelectionSheetState<T> extends ConsumerState<SelectionSheet<T>>
|
||||
subtitle: subtitle != null ? TooltipLabel(subtitle) : null,
|
||||
title: widget.titleBuilder?.call(item, title) ?? title,
|
||||
isSelected: isSelected,
|
||||
trailing: isSelected ? const GlyphIcon(AppGlyphs.check) : null,
|
||||
trailing: isSelected ? const SelectionCheck() : null,
|
||||
),
|
||||
);
|
||||
if (!widget.removeOnSelect) {
|
||||
@@ -293,13 +293,12 @@ class _SelectionSheetState<T> extends ConsumerState<SelectionSheet<T>>
|
||||
: null,
|
||||
body: Builder(
|
||||
builder: (context) => NullStatusSwitcher(
|
||||
isLoading: loadingStatus != null,
|
||||
isEmpty:
|
||||
loadingStatus != null ||
|
||||
count == 0 && (widget.emptyLabel != null || _query.isNotEmpty),
|
||||
isSearching: loadingStatus == null && _query.isNotEmpty,
|
||||
holdsArrival: loadingStatus != null,
|
||||
nullStatus:
|
||||
loadingStatus ?? NullStatus(label: widget.emptyLabel ?? ''),
|
||||
isSearching: _query.isNotEmpty,
|
||||
loadingStatus: loadingStatus,
|
||||
nullStatus: NullStatus(label: widget.emptyLabel ?? ''),
|
||||
child: CustomScrollView(
|
||||
controller: _controller,
|
||||
slivers: [
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/common/ip_quality.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/config.dart';
|
||||
import 'package:fl_clash/providers/ip_quality.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
@@ -95,12 +95,12 @@ class _IpQualityDetail extends ConsumerWidget {
|
||||
padding: const EdgeInsets.fromLTRB(_tipInset, 8, _tipInset, 0),
|
||||
child: Text(
|
||||
localizations.detectionTip,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: AppTextStyle.body
|
||||
.of(context)
|
||||
.copyWith(color: context.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
generateSectionV3(
|
||||
generateSection(
|
||||
title: localizations.basicInfo,
|
||||
items: [
|
||||
DetailRow(
|
||||
@@ -121,12 +121,12 @@ class _IpQualityDetail extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
if (!failed)
|
||||
generateSectionV3(
|
||||
generateSection(
|
||||
title: localizations.network,
|
||||
items: _networkRows(context, quality),
|
||||
),
|
||||
if (failed && error is IpQualityLookupException)
|
||||
generateSectionV3(
|
||||
generateSection(
|
||||
title: localizations.ipQualitySources,
|
||||
items: [
|
||||
for (final failure in error.failures)
|
||||
@@ -136,7 +136,7 @@ class _IpQualityDetail extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
generateSectionV3(
|
||||
generateSection(
|
||||
title: localizations.other,
|
||||
items: const [_HideIpItem()],
|
||||
),
|
||||
|
||||
@@ -1,54 +1,36 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/config.dart';
|
||||
import 'package:fl_clash/providers/ip_quality.dart';
|
||||
import 'package:fl_clash/ui/ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import 'ip_quality_sheet.dart';
|
||||
import 'labels.dart';
|
||||
|
||||
class IpQualityText extends ConsumerStatefulWidget {
|
||||
const IpQualityText({
|
||||
super.key,
|
||||
required this.ip,
|
||||
this.style,
|
||||
this.openDetails = false,
|
||||
});
|
||||
class IpQualityText extends ConsumerWidget {
|
||||
const IpQualityText({super.key, required this.ip, this.style});
|
||||
|
||||
final String ip;
|
||||
final TextStyle? style;
|
||||
final bool openDetails;
|
||||
|
||||
@override
|
||||
ConsumerState<IpQualityText> createState() => _IpQualityTextState();
|
||||
}
|
||||
|
||||
class _IpQualityTextState extends ConsumerState<IpQualityText> {
|
||||
static const _mask = '*****';
|
||||
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ip = widget.ip;
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final quality = ref.watch(ipQualityProvider(ip)).value;
|
||||
final hideIp = ref.watch(
|
||||
appSettingProvider.select((state) => state.hideIp),
|
||||
);
|
||||
final shownIp = hideIp ? _mask : ip;
|
||||
final color = quality?.level.color(context);
|
||||
var style = widget.style;
|
||||
var style = this.style;
|
||||
if (hideIp) {
|
||||
style = (style ?? const TextStyle()).toJetBrainsMono;
|
||||
}
|
||||
if (color != null) {
|
||||
style = (style ?? const TextStyle()).copyWith(color: color);
|
||||
}
|
||||
if (widget.openDetails && !_isHovered) {
|
||||
style = style?.copyWith(color: style.color?.opacity80);
|
||||
}
|
||||
final text = Tooltip(
|
||||
return Tooltip(
|
||||
preferBelow: false,
|
||||
message: shownIp,
|
||||
child: Text(
|
||||
@@ -58,17 +40,5 @@ class _IpQualityTextState extends ConsumerState<IpQualityText> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
if (!widget.openDetails) {
|
||||
return text;
|
||||
}
|
||||
return InkWell(
|
||||
onTap: () => showIpQualitySheet(context, ip: ip),
|
||||
onHover: (value) => setState(() => _isHovered = value),
|
||||
customBorder: AppShape.xs,
|
||||
hoverColor: Colors.transparent,
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
child: text,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user