Compare commits
14 Commits
main
...
v0.8.99-pre.5
| Author | SHA1 | Date | |
|---|---|---|---|
| fba3f3c8b1 | |||
| 4418517709 | |||
| e2ae3f387f | |||
| 444abb19fa | |||
| a4e22ce3e8 | |||
| 0ea30a2d97 | |||
| 0d02b62551 | |||
| d0d0d5a5fb | |||
| 879ed8870e | |||
| e40f5832ba | |||
| 72a886cb47 | |||
| e59d01ad51 | |||
| 9c0f8bbcd2 | |||
| 4db331bed0 |
@@ -230,11 +230,22 @@ surface. It is shown only outside dashboard edit mode and only when `coreLib ==
|
||||
|
||||
Proxy delay testing follows the same failure-safe UI rule. `ProxiesAction._runDelayTests` marks its keys in
|
||||
`pendingDelayTestsProvider` and releases them in `finally`, and a test the Core does not answer leaves the last
|
||||
measurement in place; the delay-test bullets under Core API Safety in `.agents/rules.md` give the reasons. A group's delay
|
||||
measurement in place; the delay-test bullets under Core API Safety in `.agents/rules.md` give the reasons. A key stays
|
||||
`queued` until the `TaskPool` actually runs it, so node cards animate a loader for at most `maxConcurrentDelayTests`
|
||||
nodes and show a static glyph for the rest; a loader on every queued node made fast scrolling drop frames. A group's delay
|
||||
test button, the tab page's action and the list page's group header alike, spins while `delayTestingGroupsProvider`
|
||||
holds the group; `delayTestPageGroup` marks it, ignores a second request for the same group, and clears it in `finally`,
|
||||
so an RPC failure cannot leave the button spinning and remounting a header does not lose the state.
|
||||
|
||||
The dashboard's probe cards show a failure as still checking for five seconds after they see `proxied` turn true,
|
||||
through `ProbeStartHold` in `lib/views/dashboard/probe_start_hold.dart`: `ServiceStatusCard` holds its check and the
|
||||
exit IP beside it, and `NetworkDetection` holds the exit IP, whose tap to retry stays inert meanwhile.
|
||||
`SetupAction.setRunning` flips the run time, and with it `proxied`, before the Core has brought its listeners up, so the
|
||||
probe launched on the spot often fails, and the config re-apply that follows every start probes again moments later;
|
||||
without the hold a card flashes a failure and then takes it back. The hold is local display state like the connecting
|
||||
hold above: the cache still records the failure, the hold arms only on an observed transition, a result that is not a
|
||||
failure shows at once, a stop cancels it, and a failure still standing when it ends is shown.
|
||||
|
||||
## Route Consistency
|
||||
|
||||
A probe result on the dashboard — the exit IP, a node's exit IP, a service check — either describes the route in
|
||||
@@ -242,12 +253,14 @@ force or is being asked again. The full design is `docs/plans/probe-route-consis
|
||||
parts.
|
||||
|
||||
- The Core is the source of truth for the route. `core/route.go` keeps a structural epoch (advanced by a config
|
||||
apply, a mode or IPv6 switch, and any proxy provider whose version changed) and a `picks` map from each Selector,
|
||||
URLTest and Fallback group to what it resolves to right now, versioned on every change. `handleChangeProxy`
|
||||
re-reads the picks under `selectMu` and answers `ChangeProxyResult{changed}`, so the connection reset only runs when
|
||||
the pick moved. A health check moving a URLTest pick has no mihomo hook, so the picks are polled every second while
|
||||
the host watches (`watchRoute(true)`) and the listeners are up. Every probe answer carries the counters it started
|
||||
under; `routeChanged` publishes the state whenever it differs from the last read.
|
||||
apply, a mode or IPv6 switch, any proxy provider whose version changed, and any rule set that loaded new content,
|
||||
whether the user or its own interval updated it) and a `picks` map from each Selector, URLTest and Fallback group
|
||||
to what it resolves to right now, versioned on every change. `handleChangeProxy` re-reads the picks under
|
||||
`selectMu` and answers `ChangeProxyResult{changed}`, so the connection reset only runs when the pick moved. A
|
||||
health check moving a URLTest pick has no mihomo hook, so the picks are polled every second while the host watches
|
||||
(`watchRoute(true)`) and the listeners are up; the same poll notices a provider or rule set that updated in the
|
||||
background. Every probe answer carries the counters it started under; `routeChanged` publishes the state whenever
|
||||
it differs from the last read.
|
||||
- `routeTrackerProvider` in `lib/providers/route_state.dart` merges that with the host's side: `proxied` (running and
|
||||
not suspended), a host epoch that a connectivity event moves, and `synced`, which is false until `watchRoute` has
|
||||
returned a snapshot and again after a Core disconnect. Nothing is fresh while unsynced, so a cache never judges
|
||||
@@ -352,6 +365,24 @@ in `lib/views/config/network.dart` and `_appSettingToggle` in `lib/views/config/
|
||||
`lib/views/dashboard/widgets/quick_options.dart`. Rows with bespoke behaviour — a custom dialog, a derived value, or a
|
||||
second provider write — stay hand-written rather than growing extra parameters on the shared items.
|
||||
|
||||
## Keyboard Insets
|
||||
|
||||
The soft keyboard moves `MediaQuery.viewInsets` on every frame of its animation, so whatever depends on them rebuilds
|
||||
and relays out at frame rate for a few hundred milliseconds. `ThemeManager` wraps the whole app in a `LayoutBuilder`,
|
||||
which runs every one of those rebuilds inside the layout phase; in a profile trace they show up there, not under
|
||||
`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
|
||||
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`, the keyboard spacer in `CommonScaffold` and the keyboard watch in
|
||||
`SnapSheet` do.
|
||||
|
||||
## State Management
|
||||
|
||||
Provider files in `lib/providers/`:
|
||||
@@ -522,10 +553,15 @@ action notifier, which is only ever read. A derived provider it listens to is ne
|
||||
of it returns a stale value. So an action that has to follow a signal listens to the keep-alive provider that holds
|
||||
it, as `SetupAction` does with `appVisibleProvider`, not to a provider derived from that.
|
||||
|
||||
On Android, `SuspendModule` suspends the Core while the screen is off and the device is in Doze. Health checks that
|
||||
run in Doze all fail, so after a suspension the Core re-probes every provider. That re-probe waits for the screen to
|
||||
come on. A Doze maintenance window resumes the Core too, but probing there would spend the window's radio time on
|
||||
results nobody sees.
|
||||
On Android the Core does not react to the screen or to Doze. Doze's firewall keeps apps without an exemption off the
|
||||
network before their traffic reaches the TUN, so what still arrives is traffic Doze allows, FCM among it; suspending the
|
||||
tunnel rejects exactly that and stalls push reconnects until their backoff runs out. The VPN service keeps its own
|
||||
network as a foreground service (`dumpsys netpolicy` shows its `DOZE` block allowed by `FOREGROUND`), so health checks
|
||||
keep producing real results: holding them blocks the re-check mihomo runs after repeated dial failures, which is how a
|
||||
`url-test` group leaves a dead node, and hiding their failures hides the truth. Nothing re-probes on resume either:
|
||||
every proxy group is a provider, `select` groups included, and checking one tests every node in it. Should a ROM be
|
||||
shown to cut the VPN's own network while the screen is off, holding back failures measured then needs only a screen
|
||||
on/off signal.
|
||||
|
||||
## Database
|
||||
|
||||
@@ -823,14 +859,50 @@ Crashlytics NDK symbolicates a native crash only from libraries that still carry
|
||||
|
||||
`CodeForgeController` is the document: text lives in the Rust `RopeBridge`, and the controller's methods are split by
|
||||
concern into `lib/code_forge/controller/` extensions. `CodeForge` builds `_CodeFieldRenderer`, whose painting, layout,
|
||||
folding, caret and gutter code sit in `lib/code_forge/code_area/` part files.
|
||||
folding, caret, gutter and pointer code sit in `lib/code_forge/code_area/` part files.
|
||||
|
||||
- Rust counts offsets in Unicode scalars and Flutter in UTF-16 code units. Convert at the boundary with
|
||||
`text_offsets.dart`; a mixed-up offset only shows up with astral characters such as emoji, so tests use them.
|
||||
- The renderer follows edits through the controller's `lineEdit` (first line, lines removed, lines inserted) and never
|
||||
reads the whole text per edit; a whole-text `text =` assignment resets the renderer instead.
|
||||
- The renderer learns what changed from `takeChange()`, whose `lines` (first line, lines removed, lines inserted) let it
|
||||
follow edits without reading the whole text; a whole-text `text =` assignment marks the change `replaced` and resets
|
||||
the renderer instead.
|
||||
- The input method is handed a window of the document around the caret, at most 4096 characters, and its deltas are
|
||||
mapped through that window, so the platform has to hold the current one: a caret move that shifts the window, the
|
||||
input method's own included, sends it again. A selection can reach past the window; when the platform removes the
|
||||
selection it sees, the controller removes the rest with it, as one undo step. Gboard does that by collapsing the
|
||||
selection and deleting what it saw, then composes the typed letter before a newly sent window reaches it: deltas
|
||||
made on a window already replaced are not mapped through the new one, and only what they typed is kept. A line
|
||||
longer than the window gets one cut by character count, which stays where it is until the caret nears an end of it,
|
||||
or every key would replace it. A caret move can put a window of the very same text further along; an edit the
|
||||
platform then makes at the caret it had is mapped through the window it still holds.
|
||||
- A selection the input method collapsed is still the one to replace only while the caret rests at the end it was
|
||||
left at; Gboard's spacebar caret gesture collapses to the middle and moves on, which lets it go. The platform's own
|
||||
cut (Gboard's text-editing panel) arrives as a lone deletion of the live selection and copies only the window's
|
||||
part, so the controller rewrites the clipboard with the whole selection. That panel's select-all and copy still
|
||||
reach only the window. Gboard types over a selection in two batches, the removal and then the letter: the second
|
||||
joins the undo step of the first, and undoing it gives the selection back.
|
||||
- A composition is drawn over the document and written into it only when it ends, so whatever reads or rewrites the
|
||||
document from outside the input method commits it first: saving, leaving the page, accepting a suggestion, a
|
||||
snippet, find's replace, and `UndoRedoController`'s `undo`/`redo` through the `settleInput` callback the controller
|
||||
registers. Losing focus and a closing connection commit it too rather than drop it. Typing that ends a composition,
|
||||
Enter or a bracket, goes through the typing path so it still indents and pairs. Android ends one with an empty
|
||||
delta batch when the keyboard is hidden.
|
||||
- A caret the input method moves has to set `selection` on the change record like any other move, or the renderer
|
||||
neither repaints nor follows it.
|
||||
- A line's fold comes from the renderer's `_computeFoldRangeForLine` up to 10000 lines and from `compute_all` in
|
||||
`rust_api`'s `editor/folds.rs` past that, and the two must find the same ranges; `folding_test.dart` compares them on
|
||||
one document. A folded range, and the folded ranges inside it that it folds again when it opens, outlive an edit
|
||||
only while the text still gives that range. An edit reports the lines it changed, not where in them, so a fold
|
||||
heading a line inside an edit that changes the line count is opened rather than guessed at; `replaceText`, which
|
||||
find's replace-all uses, rewrites only the span that differs to keep that to the lines between its matches.
|
||||
- The caret never rests on a line a fold hides: folding over it moves it to the heading line, the keyboard steps over
|
||||
the fold, and a caret put there any other way opens it.
|
||||
- The controller reaches the mounted editor only through the `CodeForgeView` it attaches, which the editor's state
|
||||
implements by forwarding to its renderer.
|
||||
- FlClash supplies the suggestion popup, context menu and scrollbar; the package has no built-in versions of them.
|
||||
- Tests live in `test/plugins/code_forge/` at the repository root and need the `rust_api` library.
|
||||
- `isMobile` comes from `Platform`, so host tests never reach the touch branches (selection handles, long press,
|
||||
magnifier); check those on an Android emulator.
|
||||
|
||||
## window Plugin
|
||||
|
||||
@@ -848,9 +920,13 @@ table. `lib/common/window.dart` and `lib/manager/window_manager.dart` are the on
|
||||
- Position and size changes surface as one `geometry-changed` event that each platform coalesces natively (150 ms
|
||||
after the last `WM_WINDOWPOSCHANGED`, did-move/did-resize, or configure-event); `WindowManager` only debounces the
|
||||
capture on top of it.
|
||||
- Windows: with `titleBarStyle: hidden`, `WM_NCCALCSIZE` lets `DefWindowProc` compute the client rect and then puts
|
||||
the top edge back (plus the frame height when maximized, so the taskbar stays uncovered; plus 1 px otherwise, which
|
||||
Windows 11 needs to draw the top border and Windows 10 needs to avoid a white line). Fullscreen is emulated by the
|
||||
- Windows: with `titleBarStyle: hidden`, `WM_NCCALCSIZE` takes the frame insets from `AdjustWindowRectExForDpi` and
|
||||
keeps the client rect at the top edge (plus the frame height when maximized, so the taskbar stays uncovered; plus
|
||||
1 px on Windows 11, which draws the top border only into a non-client strip; none on Windows 10, where DWM draws
|
||||
the whole caption over any strip shorter than one). Windows 10 gets its top border the Windows Terminal way
|
||||
instead: the DWM frame is extended over the caption height, the plugin places the Flutter view 1 px lower and
|
||||
paints that row black, which DWM treats as alpha 0, and hit-tests it as `HTTOP`; see `plugins/window/README.md`.
|
||||
The plugin therefore owns the view placement on `WM_SIZE`. Fullscreen is emulated by the
|
||||
plugin, which raises `enter-full-screen` and
|
||||
`leave-full-screen` itself and mutes `WM_SIZE` while it is on. The find-running-window IPC
|
||||
(`WindowPluginFindRunningWindow`/`WindowPluginActivateWindow`) registers a window message from the executable path
|
||||
@@ -900,10 +976,10 @@ the singleton entry point: `RustLib.init()` runs before any call and `RustLib.di
|
||||
bounded send queue), `platform` (socket cleanup, Windows peer credentials and the non-blocking pipe reader), and
|
||||
`server` (lifecycle, accept loop, and the `RUNNING`/`STATE` globals).
|
||||
- `script/` runs profile override scripts on QuickJS through `rquickjs`.
|
||||
- `editor/` backs `plugins/code_forge`: a `ropey` buffer with its selection, fold ranges and bracket matching,
|
||||
indent guides for the viewport, and completion words. `api/editor.rs` exposes the buffer as the opaque
|
||||
`RopeBridge`. Only the editor imports this module, through `lib/editor.dart`, so `lib/rust_api.dart` keeps its
|
||||
generic names such as `SelectionState` out of app code.
|
||||
- `editor/` backs `plugins/code_forge`: a `ropey` buffer, fold ranges and bracket matching, indent guides for the
|
||||
viewport, and completion words. `api/editor.rs` exposes the buffer as the opaque `RopeBridge`; the selection
|
||||
stays in Dart. Only the editor imports this module, through `lib/editor.dart`, so `lib/rust_api.dart` keeps its
|
||||
generic names such as `GuideBlock` out of app code.
|
||||
- `hotkey/` registers desktop global shortcuts through `global-hotkey`: `keys` maps Flutter USB HID usages to key
|
||||
codes, `owner` runs every registration on the thread the platform binds it to (a dedicated message-loop thread on
|
||||
Windows, the main dispatch queue on macOS, in place on Linux), and `service` owns the registry and forwards presses
|
||||
|
||||
@@ -128,7 +128,11 @@ dart run tool/generate_status_icons.dart
|
||||
|
||||
It writes the tray PNGs with Flutter `2.0x/`–`4.0x/` resolution variants to `assets/images/tray/unix/`,
|
||||
multi-size tray `.ico` files to `assets/images/tray/windows/`, and `windows/runner/resources/app_icon.ico`
|
||||
from `assets/images/icon.png`. `pubspec.yaml` declares the two tray directories with `platforms:` so each
|
||||
from `assets/images/icon.png`. macOS draws its menu bar icon as a template, so it skips the ringed color
|
||||
icons, whose logo shrinks to a small glyph once only alpha shows: `status_1` renders the bare logo from
|
||||
`assets_source/images/icon/glyph.svg` and `status_4` reuses `status_4.svg`, both recolored to black into
|
||||
`assets/images/tray/macos/`; the sources keep their colors. `glyph.svg` is also the geometry of the Android notification drawables
|
||||
`android/service/src/main/res/drawable/ic.xml` and `ic_service.xml`; change them together. `pubspec.yaml` declares the three tray directories with `platforms:` so each
|
||||
build only bundles the format its tray loads; a new status icon needs a source SVG and an entry in the
|
||||
script's `statusIconNames`, nothing in `pubspec.yaml`.
|
||||
|
||||
|
||||
@@ -29,3 +29,45 @@ listed in the SDK's `CHANGELOG.md`.
|
||||
changelog lists #191538). Before dropping the step, build the Intel DMG without it and scroll the proxies and
|
||||
dashboard pages on an Intel Mac with its integrated GPU driving the built-in display; an external display can
|
||||
switch to the discrete GPU and hide the flicker.
|
||||
|
||||
## Windows view visibility forwarded by the runner
|
||||
|
||||
- **Bug:** the Windows embedder's `WindowsLifecycleManager` counts the Flutter view as a window of its own and keeps
|
||||
it visible until the view is sized to zero. Hiding the top-level window never resizes the view, and the `window`
|
||||
plugin keeps it sized while minimized, so the app was never told `hidden`: animations kept rendering in the tray
|
||||
and `appVisibleProvider` stayed true.
|
||||
- **Workaround:** `WindowPluginForwardVisibility` in the `window` plugin's
|
||||
`windows/include/window/window_plugin_runner.h` hands the top-level window's `WM_SHOWWINDOW` and `WM_SIZE` to
|
||||
`ProcessExternalWindowMessage` for the view's HWND, so the view shows and hides with its window.
|
||||
`FlutterWindow::MessageHandler` in `windows/runner/flutter_window.cpp` calls it before `HandleTopLevelWindowProc`;
|
||||
the Flutter runner template has no such call, so keep it when regenerating the runner.
|
||||
- **Remove when:** the engine hides the view together with its top-level window. Check with the proxy running and
|
||||
the window focused: hiding it to the tray and minimizing it must each log `AppLifecycleState.hidden`.
|
||||
|
||||
## macOS activation resumes a hidden window
|
||||
|
||||
- **Bug:** `handleWillBecomeActive` in the macOS engine's `FlutterEngine.mm` resumes when any window in
|
||||
`NSApp.windows` is visible, and the tray's `NSStatusBarWindow` always is. Activating the app while its window was
|
||||
minimized or hidden, such as Cmd-Tab back to a minimized window, reported `resumed`, and the next deactivation
|
||||
`inactive`, so frames ran until the window was shown again.
|
||||
- **Workaround:** the `window` plugin observes will-become-active and will-resign-active and, once the engine has
|
||||
answered, sends `AppLifecycleState.hidden` on `flutter/lifecycle` while its window is not visible
|
||||
(`applicationActivityChanged` in `WindowPlugin.swift`). A window brought back without another activation change
|
||||
would then wait on an occlusion notification macOS does not always post, keeping its last frame, so `show` and a
|
||||
deminiaturize hand the state back (`windowBecameVisible`).
|
||||
- **Remove when:** the engine ignores status item windows on activation. Check without the workaround: minimize the
|
||||
window, switch to another app, then Cmd-Tab back without restoring it; the log must stay at
|
||||
`AppLifecycleState.hidden`.
|
||||
|
||||
## Linux minimize on Wayland reads as inactive
|
||||
|
||||
- **Bug:** the Linux engine reports `hidden` for `GDK_WINDOW_STATE_ICONIFIED`, but xdg-shell has no minimized state,
|
||||
so GTK 3 never sets it on Wayland. A minimized window reported only its focus loss, as `inactive`, and kept
|
||||
rendering at the display rate (GNOME 46).
|
||||
- **Workaround:** the `window` plugin's Linux `minimize` records the time, and a focus loss within 500 ms of it sends
|
||||
`AppLifecycleState.hidden`. Its `window-state-event` handler is connected after the engine's, so that message
|
||||
follows the engine's `inactive`; the compositor focuses the window when it restores it, which the engine reports as
|
||||
`resumed`. A minimize the compositor starts itself (Super+H, the dock) is not covered.
|
||||
- **Remove when:** the Linux engine reports minimized windows on Wayland, which needs GTK 4 or xdg-shell's
|
||||
`suspended` state. Check in a GNOME Wayland session without the workaround: minimizing from the caption button
|
||||
must log `AppLifecycleState.hidden`.
|
||||
|
||||
@@ -27,6 +27,9 @@ dialogs, and interaction behavior.
|
||||
- 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.
|
||||
- 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.
|
||||
- App bar buttons reach `CommonScaffold` as data (`primaryAction`, `iconActions`, `menuItems`) so it can fold them into
|
||||
its overflow menu; App Bar Actions in `.agents/architecture.md` has the rule.
|
||||
- User-facing text goes through ARB; use `localization` when text changes are non-trivial.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# freezed emits lines of trailing spaces, and generated code is never edited by hand.
|
||||
lib/**/generated/** -whitespace
|
||||
@@ -42,9 +42,9 @@ object ServiceController {
|
||||
binding = null
|
||||
}
|
||||
|
||||
fun invokeMethod(data: String, callback: (String) -> Unit): Result<Unit> = runCatching {
|
||||
fun invokeMethod(data: String, callback: (ByteArray) -> Unit): Result<Unit> = runCatching {
|
||||
Core.invokeMethod(data) { result ->
|
||||
callback(result.orEmpty())
|
||||
callback(result ?: ByteArray(0))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -272,12 +272,13 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun updateExcludeFromRecents(value: Boolean?) {
|
||||
val taskId = activity?.taskId ?: return
|
||||
val am = getSystemService(GlobalState.application, ActivityManager::class.java)
|
||||
val task = am?.appTasks?.firstOrNull {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
it.taskInfo.taskId == activity?.taskId
|
||||
it.taskInfo?.taskId == taskId
|
||||
} else {
|
||||
it.taskInfo.id == activity?.taskId
|
||||
it.taskInfo?.id == taskId
|
||||
}
|
||||
}
|
||||
task?.setExcludeFromRecents(value ?: false)
|
||||
|
||||
@@ -67,12 +67,13 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
result.error("INVALID_ARGUMENT", "Method call payload must be a string", null)
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
ServiceController.invokeMethod(data) { response ->
|
||||
result.success(response)
|
||||
}.onFailure { error ->
|
||||
result.error("CORE_ERROR", error.message, null)
|
||||
}
|
||||
// Inline rather than a coroutine per call, which could swap two: watchRoute
|
||||
// keeps the latest intent by the order calls reach the Core
|
||||
// (dispatchMethodCall in core/method.go), and this only hands data over.
|
||||
ServiceController.invokeMethod(data) { response ->
|
||||
result.success(response)
|
||||
}.onFailure { error ->
|
||||
result.error("CORE_ERROR", error.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,19 +59,24 @@ val Intent.toPendingIntent: PendingIntent
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
fun Service.startForeground(notification: Notification) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
var channel = manager?.getNotificationChannel(GlobalState.NOTIFICATION_CHANNEL)
|
||||
if (channel == null) {
|
||||
channel = NotificationChannel(
|
||||
GlobalState.NOTIFICATION_CHANNEL,
|
||||
getString(R.string.service_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
)
|
||||
manager?.createNotificationChannel(channel)
|
||||
}
|
||||
fun Context.ensureNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java) ?: return
|
||||
if (manager.getNotificationChannel(GlobalState.NOTIFICATION_CHANNEL) != null) {
|
||||
return
|
||||
}
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
GlobalState.NOTIFICATION_CHANNEL,
|
||||
getString(R.string.service_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun Service.startForeground(notification: Notification) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
GlobalState.NOTIFICATION_ID,
|
||||
|
||||
@@ -9,8 +9,8 @@ message("CMAKE_BUILD_TYPE ${CMAKE_BUILD_TYPE}")
|
||||
|
||||
if (NOT "${CMAKE_BUILD_TYPE}" STREQUAL "Debug")
|
||||
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
|
||||
add_compile_options(-O3 -flto -g0 -fno-exceptions -fno-rtti)
|
||||
add_link_options(-flto -Wl,--gc-sections,--strip-all)
|
||||
add_compile_options(-O3 -flto -g -fno-exceptions -fno-rtti)
|
||||
add_link_options(-flto -Wl,--gc-sections)
|
||||
endif ()
|
||||
|
||||
set(LIB_CLASH_INCLUDE_DIR "${CMAKE_SOURCE_DIR}/../cpp/includes/${ANDROID_ABI}")
|
||||
|
||||
@@ -68,12 +68,6 @@ Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
|
||||
return new_string(traffic);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_suspended(JNIEnv *env, jobject thiz, jboolean suspended) {
|
||||
suspend(suspended);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_quickSetup(JNIEnv *env, jobject thiz, jstring init_params_string,
|
||||
@@ -160,7 +154,7 @@ static void call_invoke_interface_result_impl(void *invoke_interface, const char
|
||||
return;
|
||||
}
|
||||
ATTACH_JNI();
|
||||
const auto value = new_string(data);
|
||||
const auto value = new_bytes(data);
|
||||
env->CallVoidMethod(static_cast<jobject>(invoke_interface),
|
||||
m_invoke_interface_result,
|
||||
value);
|
||||
@@ -190,7 +184,7 @@ JNI_OnLoad(JavaVM *vm, void *) {
|
||||
m_tun_interface_resolve_package = find_method(c_tun_interface, "resolvePackage",
|
||||
"(I)Ljava/lang/String;");
|
||||
m_invoke_interface_result = find_method(c_invoke_interface, "onResult",
|
||||
"(Ljava/lang/String;)V");
|
||||
"([B)V");
|
||||
|
||||
|
||||
protect_func = &call_tun_interface_protect_impl;
|
||||
|
||||
@@ -93,6 +93,20 @@ jstring jni_new_string(JNIEnv *env, const char *str) {
|
||||
return result;
|
||||
}
|
||||
|
||||
jbyteArray jni_new_bytes(JNIEnv *env, const char *str) {
|
||||
const auto length = str == nullptr ? 0 : static_cast<int>(strlen(str));
|
||||
const auto array = env->NewByteArray(length);
|
||||
if (jni_clear_exception(env) || array == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
env->SetByteArrayRegion(array, 0, length, reinterpret_cast<const jbyte *>(str));
|
||||
if (jni_clear_exception(env)) {
|
||||
env->DeleteLocalRef(array);
|
||||
return nullptr;
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
void jni_attach_thread(scoped_jni *jni) {
|
||||
if (global_vm->GetEnv(reinterpret_cast<void **>(&jni->env), JNI_VERSION_1_6) == JNI_OK) {
|
||||
jni->require_release = 0;
|
||||
|
||||
@@ -11,6 +11,8 @@ extern void initialize_jni(JavaVM *vm, JNIEnv *env);
|
||||
|
||||
extern jstring jni_new_string(JNIEnv *env, const char *str);
|
||||
|
||||
extern jbyteArray jni_new_bytes(JNIEnv *env, const char *str);
|
||||
|
||||
extern char *jni_get_string(JNIEnv *env, jstring str);
|
||||
|
||||
extern void jni_attach_thread(scoped_jni *jni);
|
||||
@@ -34,3 +36,4 @@ extern bool jni_clear_exception(JNIEnv *env);
|
||||
#define del_global(obj) env->DeleteGlobalRef(obj)
|
||||
#define get_string(jstr) jni_get_string(env, jstr)
|
||||
#define new_string(cstr) jni_new_string(env, cstr)
|
||||
#define new_bytes(cstr) jni_new_bytes(env, cstr)
|
||||
|
||||
@@ -60,10 +60,6 @@ object Core {
|
||||
)
|
||||
}
|
||||
|
||||
external fun suspended(
|
||||
suspended: Boolean,
|
||||
)
|
||||
|
||||
private external fun invokeMethod(
|
||||
data: String,
|
||||
cb: InvokeInterface,
|
||||
@@ -71,12 +67,12 @@ object Core {
|
||||
|
||||
fun invokeMethod(
|
||||
data: String,
|
||||
cb: (result: String?) -> Unit,
|
||||
cb: (result: ByteArray?) -> Unit,
|
||||
) {
|
||||
invokeMethod(
|
||||
data,
|
||||
object : InvokeInterface {
|
||||
override fun onResult(result: String?) {
|
||||
override fun onResult(result: ByteArray?) {
|
||||
cb(result)
|
||||
}
|
||||
},
|
||||
@@ -93,8 +89,8 @@ object Core {
|
||||
} else {
|
||||
setEventListener(
|
||||
object : InvokeInterface {
|
||||
override fun onResult(result: String?) {
|
||||
callback(result)
|
||||
override fun onResult(result: ByteArray?) {
|
||||
callback(result?.decodeToString())
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -110,8 +106,8 @@ object Core {
|
||||
initParamsString,
|
||||
setupParamsString,
|
||||
object : InvokeInterface {
|
||||
override fun onResult(result: String?) {
|
||||
callback(result)
|
||||
override fun onResult(result: ByteArray?) {
|
||||
callback(result?.decodeToString())
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4,5 +4,5 @@ import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
interface InvokeInterface {
|
||||
fun onResult(result: String?)
|
||||
fun onResult(result: ByteArray?)
|
||||
}
|
||||
@@ -47,13 +47,13 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
|
||||
private val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
networkInfos[network] = NetworkInfo()
|
||||
updateDns()
|
||||
scheduleUpdateDns()
|
||||
}
|
||||
|
||||
override fun onLosing(network: Network, maxMsToLive: Int) {
|
||||
val info = networkInfos[network] ?: return
|
||||
info.losingUntilMillis = System.currentTimeMillis() + maxMsToLive
|
||||
updateDns()
|
||||
scheduleUpdateDns()
|
||||
if (maxMsToLive > 0) {
|
||||
mainHandler.postDelayed({
|
||||
if (networkInfos.containsKey(network)) {
|
||||
@@ -65,15 +65,24 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
networkInfos.remove(network)
|
||||
updateDns()
|
||||
scheduleUpdateDns()
|
||||
}
|
||||
|
||||
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
|
||||
networkInfos[network]?.dnsList = linkProperties.dnsServers
|
||||
updateDns()
|
||||
scheduleUpdateDns()
|
||||
}
|
||||
}
|
||||
|
||||
private val updateDnsTask = Runnable(::updateDns)
|
||||
|
||||
// A switch arrives as a burst, and a network that just came up has no DNS
|
||||
// until its link properties follow; the Core flushes its cache per list.
|
||||
private fun scheduleUpdateDns() {
|
||||
mainHandler.removeCallbacks(updateDnsTask)
|
||||
mainHandler.postDelayed(updateDnsTask, DNS_UPDATE_DELAY_MILLIS)
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
updateDns()
|
||||
connectivity?.registerNetworkCallback(request, callback)
|
||||
@@ -126,6 +135,7 @@ internal class NetworkObserveModule(private val service: Service) : ServiceModul
|
||||
}
|
||||
|
||||
private const val DNS_PORT = 53
|
||||
private const val DNS_UPDATE_DELAY_MILLIS = 200L
|
||||
|
||||
private fun InetAddress.asSocketAddressText(port: Int): String = when (this) {
|
||||
is Inet6Address -> "[$hostAddress]:$port"
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.core.content.getSystemService
|
||||
import com.follow.clash.common.Components
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.QuickAction
|
||||
import com.follow.clash.common.ensureNotificationChannel
|
||||
import com.follow.clash.common.quickIntent
|
||||
import com.follow.clash.common.receiveBroadcastFlow
|
||||
import com.follow.clash.common.startForeground
|
||||
@@ -22,9 +23,9 @@ import com.follow.clash.service.models.NotificationParams
|
||||
import com.follow.clash.service.models.getSpeedTrafficText
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
@@ -50,31 +51,31 @@ internal class NotificationModule(
|
||||
private val scope: CoroutineScope,
|
||||
) : ServiceModule {
|
||||
override fun start() {
|
||||
service.ensureNotificationChannel()
|
||||
update(ServiceConfig.notificationParams.value.extended)
|
||||
scope.launch {
|
||||
val screenFlow = service.receiveBroadcastFlow {
|
||||
service.receiveBroadcastFlow {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
}.map { intent ->
|
||||
intent.action == Intent.ACTION_SCREEN_ON
|
||||
}.onStart {
|
||||
emit(isScreenOn())
|
||||
}.distinctUntilChanged().collectLatest { screenOn ->
|
||||
if (!screenOn) return@collectLatest
|
||||
combine(
|
||||
flow {
|
||||
while (true) {
|
||||
delay(1_000)
|
||||
emit(Unit)
|
||||
}
|
||||
},
|
||||
ServiceConfig.notificationParams,
|
||||
) { _, params ->
|
||||
params.extended
|
||||
}.distinctUntilChanged()
|
||||
.collect(::update)
|
||||
}
|
||||
|
||||
combine(
|
||||
flow {
|
||||
while (true) {
|
||||
delay(1_000)
|
||||
emit(Unit)
|
||||
}
|
||||
},
|
||||
ServiceConfig.notificationParams,
|
||||
screenFlow,
|
||||
) { _, params, screenOn ->
|
||||
params.takeIf { screenOn }?.extended
|
||||
}.filterNotNull()
|
||||
.distinctUntilChanged()
|
||||
.collect(::update)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +103,8 @@ internal class NotificationModule(
|
||||
}
|
||||
}
|
||||
|
||||
private val stopIntent by lazy { QuickAction.STOP.quickIntent.toPendingIntent }
|
||||
|
||||
private fun update(params: ExtendedNotificationParams) {
|
||||
service.startForeground(
|
||||
with(notificationBuilder) {
|
||||
@@ -109,11 +112,7 @@ internal class NotificationModule(
|
||||
setContentText(params.contentText)
|
||||
clearActions()
|
||||
if (params.showStopAction) {
|
||||
addAction(
|
||||
0,
|
||||
params.stopText,
|
||||
QuickAction.STOP.quickIntent.toPendingIntent,
|
||||
)
|
||||
addAction(0, params.stopText, stopIntent)
|
||||
}
|
||||
build()
|
||||
},
|
||||
|
||||
@@ -24,7 +24,6 @@ internal class ServiceModules(private val service: Service) {
|
||||
val nextModules = listOf(
|
||||
NotificationModule(service, nextScope),
|
||||
NetworkObserveModule(service),
|
||||
SuspendModule(service, nextScope),
|
||||
)
|
||||
val startedModules = mutableListOf<ServiceModule>()
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package com.follow.clash.service.modules
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.PowerManager
|
||||
import androidx.core.content.getSystemService
|
||||
import com.follow.clash.common.receiveBroadcastFlow
|
||||
import com.follow.clash.core.Core
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal class SuspendModule(
|
||||
private val service: Service,
|
||||
private val scope: CoroutineScope,
|
||||
) : ServiceModule {
|
||||
private fun isScreenOn() =
|
||||
service.getSystemService<PowerManager>()?.isInteractive ?: true
|
||||
|
||||
private val isDeviceIdle: Boolean
|
||||
get() = service.getSystemService<PowerManager>()?.isDeviceIdleMode ?: true
|
||||
|
||||
private fun updateSuspension(screenOn: Boolean) {
|
||||
Core.suspended(!screenOn && isDeviceIdle)
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
scope.launch {
|
||||
val screenFlow = service.receiveBroadcastFlow {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
|
||||
}.map {
|
||||
isScreenOn()
|
||||
}.onStart {
|
||||
emit(isScreenOn())
|
||||
}
|
||||
|
||||
screenFlow.collect(::updateSuspension)
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
Core.suspended(false)
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,12 @@
|
||||
android:viewportHeight="240"
|
||||
tools:ignore="VectorRaster">
|
||||
<path
|
||||
android:pathData="M48.1,80.89L168.44,11.41c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0l-120.34,69.48c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
|
||||
android:pathData="M48.1,80.89L168.44,11.41A23.16,23.16 0,0 1,191.6,51.53L71.26,121.01A23.16,23.16 0,0 1,48.1,80.89Z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
<path
|
||||
android:pathData="M78.98,134.37l60.18,-34.74c11.07,-6.39 25.23,-2.59 31.63,8.48h0c6.4,11.07 2.61,25.24 -8.47,31.64l-60.18,34.74c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64h0Z"
|
||||
android:pathData="M78.98,134.37L139.16,99.63A23.16,23.16 0,0 1,162.32,139.75L102.14,174.49A23.16,23.16 0,0 1,78.98,134.37Z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
<path
|
||||
android:pathData="M109.86,187.86h0c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0h0c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
|
||||
android:pathData="M98.28,207.92A23.16,23.16 0,1 1,144.6,207.92A23.16,23.16 0,1 1,98.28,207.92Z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
</vector>
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
android:viewportHeight="240"
|
||||
tools:ignore="VectorRaster">
|
||||
<path
|
||||
android:pathData="M48.1,80.89L168.44,11.41c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0l-120.34,69.48c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
|
||||
android:pathData="M48.1,80.89L168.44,11.41A23.16,23.16 0,0 1,191.6,51.53L71.26,121.01A23.16,23.16 0,0 1,48.1,80.89Z"
|
||||
android:fillColor="#6666FB"/>
|
||||
<path
|
||||
android:pathData="M78.98,134.37l60.18,-34.74c11.07,-6.39 25.23,-2.59 31.63,8.48h0c6.4,11.07 2.61,25.24 -8.47,31.64l-60.18,34.74c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64h0Z"
|
||||
android:pathData="M78.98,134.37L139.16,99.63A23.16,23.16 0,0 1,162.32,139.75L102.14,174.49A23.16,23.16 0,0 1,78.98,134.37Z"
|
||||
android:fillColor="#336AB6"/>
|
||||
<path
|
||||
android:pathData="M109.86,187.86h0c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0h0c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
|
||||
android:pathData="M98.28,207.92A23.16,23.16 0,1 1,144.6,207.92A23.16,23.16 0,1 1,98.28,207.92Z"
|
||||
android:fillColor="#5CA8E9"/>
|
||||
</vector>
|
||||
|
||||
@@ -9,19 +9,32 @@
|
||||
"profiles": "Profiles",
|
||||
"tools": "Tools",
|
||||
"logs": "Logs",
|
||||
"logsDesc": "Captured log records",
|
||||
"resources": "Resources",
|
||||
"resourcesDesc": "Information about external resources",
|
||||
"trafficUsage": "Traffic usage",
|
||||
"networkSpeed": "Network speed",
|
||||
"outboundMode": "Outbound mode",
|
||||
"networkDetection": "Network detection",
|
||||
"serviceStatus": "Service status",
|
||||
"serviceRestricted": "Access restricted",
|
||||
"serviceAvailable": "Available",
|
||||
"serviceUnavailable": "Unavailable",
|
||||
"serviceFailed": "Check failed",
|
||||
"serviceDisallowedIsp": "Disallowed ISP",
|
||||
"serviceBlocked": "Blocked",
|
||||
"serviceUnsupportedRegion": "Region not supported",
|
||||
"serviceOriginalsOnly": "Originals only",
|
||||
"serviceComingSoon": "Coming soon",
|
||||
"servicePending": "Not checked",
|
||||
"serviceManage": "Manage services",
|
||||
"serviceCheck": "Check",
|
||||
"serviceCheckAll": "Check all",
|
||||
"serviceCheckedAt": "Checked at {time}",
|
||||
"upload": "Upload",
|
||||
"download": "Download",
|
||||
"usedTraffic": "Used traffic",
|
||||
"totalTraffic": "Total traffic",
|
||||
"expireTime": "Expiration time",
|
||||
"nullProfileDesc": "No profiles yet, please add one first",
|
||||
"nullProfileDesc": "Add a profile to get started",
|
||||
"settings": "Settings",
|
||||
"language": "Language",
|
||||
"defaultText": "Default",
|
||||
@@ -36,27 +49,22 @@
|
||||
"themeDesc": "Set dark mode and adjust colors",
|
||||
"override": "Override",
|
||||
"allowLan": "Allow LAN",
|
||||
"allowLanDesc": "Allow proxy access over the LAN",
|
||||
"tun": "TUN",
|
||||
"tunDesc": "Only effective in administrator mode",
|
||||
"minimizeOnExit": "Minimize on exit",
|
||||
"minimizeOnExitDesc": "Override the default system exit behavior",
|
||||
"autoLaunch": "Auto launch",
|
||||
"autoLaunchDesc": "Launch automatically at system startup",
|
||||
"silentLaunch": "Silent launch",
|
||||
"silentLaunchDesc": "Start in the background",
|
||||
"silentLaunchDesc": "Start without showing the window",
|
||||
"autoRun": "Auto run",
|
||||
"autoRunDesc": "Run automatically when the app opens",
|
||||
"logcat": "Logcat",
|
||||
"logcatDesc": "Disabling hides the log entry point",
|
||||
"autoCheckUpdate": "Auto check for updates",
|
||||
"autoCheckUpdateDesc": "Check for updates automatically when the app starts",
|
||||
"checkCertificate": "Verify TLS certificates",
|
||||
"checkCertificateDesc": "Reject untrusted certificates. Turning this off exposes subscriptions and backups to man-in-the-middle attacks",
|
||||
"accessControl": "Access control",
|
||||
"accessControlDesc": "Control which apps use the proxy",
|
||||
"application": "Application",
|
||||
"applicationDesc": "Adjust application settings",
|
||||
"edit": "Edit",
|
||||
"confirm": "Confirm",
|
||||
"update": "Update",
|
||||
@@ -103,12 +111,15 @@
|
||||
"show": "Show",
|
||||
"exit": "Exit",
|
||||
"systemProxy": "System proxy",
|
||||
"safeMode": "Safe mode",
|
||||
"safeModeAppTitle": "{appName} (Safe mode)",
|
||||
"project": "Project",
|
||||
"core": "Core",
|
||||
"tabAnimation": "Tab animation",
|
||||
"desc": "A multi-platform proxy client based on ClashMeta, simple and easy to use, open-source and ad-free.",
|
||||
"startVpn": "Starting VPN...",
|
||||
"stopVpn": "Stopping VPN...",
|
||||
"startVpn": "Starting VPN…",
|
||||
"stopVpn": "Stopping VPN…",
|
||||
"localNetworkDeniedTip": "Local network permission denied: using the gvisor stack, LAN is unreachable.",
|
||||
"compatible": "Compatibility mode",
|
||||
"notSelectedTip": "The current proxy group cannot be selected",
|
||||
"changeProxyFailedTip": "Failed to switch proxy; the previous selection has been restored",
|
||||
@@ -137,32 +148,31 @@
|
||||
"country": "Region",
|
||||
"search": "Search",
|
||||
"allowBypass": "Allow apps to bypass VPN",
|
||||
"allowBypassDesc": "When enabled, some apps can bypass the VPN",
|
||||
"externalController": "External controller",
|
||||
"externalControllerDesc": "When enabled, the Clash core can be controlled on port 9090",
|
||||
"ipv6Desc": "When enabled, IPv6 traffic can be received",
|
||||
"app": "App",
|
||||
"general": "General",
|
||||
"systemProxyDesc": "Set the system proxy",
|
||||
"startupAndBackground": "Startup and background",
|
||||
"requestsAndUpdates": "Requests and updates",
|
||||
"logsAndDiagnostics": "Logs and diagnostics",
|
||||
"userAgent": "User-Agent",
|
||||
"unifiedDelay": "Unified delay",
|
||||
"unifiedDelayDesc": "Remove extra delays such as handshakes",
|
||||
"tcpConcurrent": "TCP concurrent",
|
||||
"tcpConcurrentDesc": "Allow concurrent TCP connections",
|
||||
"geodataLoader": "Geo low-memory mode",
|
||||
"geodataLoaderDesc": "Use the low-memory Geo loader",
|
||||
"requests": "Requests",
|
||||
"requestsDesc": "View recent request records",
|
||||
"recentRequests": "Recent requests",
|
||||
"findProcessMode": "Find process",
|
||||
"init": "Init",
|
||||
"infiniteTime": "Never expires",
|
||||
"connections": "Connections",
|
||||
"liveConnections": "Live connections",
|
||||
"inbound": "Inbound",
|
||||
"connectionsDesc": "View current connection data",
|
||||
"intranetIP": "Intranet IP",
|
||||
"view": "View",
|
||||
"cut": "Cut",
|
||||
"copy": "Copy",
|
||||
"clipboardWriteFailed": "Couldn't copy to the clipboard. The selection may be too large",
|
||||
"paste": "Paste",
|
||||
"testUrl": "Test URL",
|
||||
"sync": "Sync",
|
||||
@@ -182,19 +192,18 @@
|
||||
"go": "Go",
|
||||
"externalLink": "External link",
|
||||
"otherContributors": "Other contributors",
|
||||
"appIconDesign": "App icon design",
|
||||
"autoCloseConnections": "Auto close connections",
|
||||
"autoCloseConnectionsDesc": "Close connections automatically after switching nodes",
|
||||
"onlyStatisticsProxy": "Only count proxy traffic",
|
||||
"onlyStatisticsProxyDesc": "When enabled, only proxy traffic is counted",
|
||||
"showNotificationStopAction": "Stop button in notification",
|
||||
"showNotificationStopActionDesc": "Show a stop button on the persistent notification. Turn it off if your system keeps the notification expanded because of it",
|
||||
"pureBlackMode": "Pure black mode",
|
||||
"sidebarBlur": "Sidebar blur",
|
||||
"sidebarBlurDesc": "Show the blurred desktop behind the window through the sidebar",
|
||||
"keepAliveIntervalDesc": "TCP keep-alive interval",
|
||||
"entries": " entries",
|
||||
"local": "Local",
|
||||
"remote": "Remote",
|
||||
"remoteBackupDesc": "Back up data to WebDAV",
|
||||
"localBackupDesc": "Back up data locally",
|
||||
"mode": "Mode",
|
||||
"time": "Time",
|
||||
"source": "Source",
|
||||
@@ -208,70 +217,119 @@
|
||||
"loose": "Loose",
|
||||
"profilesSort": "Sort profiles",
|
||||
"start": "Start",
|
||||
"runTime": "Run time",
|
||||
"stop": "Stop",
|
||||
"dnsDesc": "Update DNS-related settings",
|
||||
"key": "Key",
|
||||
"value": "Value",
|
||||
"hostsDesc": "Append hosts",
|
||||
"vpnTip": "Changes take effect after restarting the VPN",
|
||||
"vpnEnableDesc": "Route all system traffic through VpnService automatically",
|
||||
"options": "Options",
|
||||
"loopback": "Loopback unlock tool",
|
||||
"loopbackDesc": "Used for UWP loopback exemption",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"loopback": "UWP loopback exemption",
|
||||
"providers": "External resources",
|
||||
"proxyProviders": "Proxy providers",
|
||||
"ruleProviders": "Rule providers",
|
||||
"behavior": "Behavior",
|
||||
"format": "Format",
|
||||
"providerUrlTip": "Only remote providers are supported",
|
||||
"subscriptionInfo": "Subscription info",
|
||||
"overrideDns": "Override DNS",
|
||||
"overrideDnsDesc": "When enabled, the DNS options in the profile are overridden",
|
||||
"overrideNtp": "Override NTP",
|
||||
"ntpStatusDesc": "Take the time from an NTP server instead of the system clock",
|
||||
"server": "Server",
|
||||
"ntpInterval": "Sync interval (minutes)",
|
||||
"dialerProxy": "Dialer proxy",
|
||||
"dialerProxyDesc": "The outbound used to reach the NTP server",
|
||||
"writeToSystem": "Write to system",
|
||||
"writeToSystemDesc": "Also set the system clock; Android ignores it",
|
||||
"overrideEntries": "Override entries",
|
||||
"addOverrideEntry": "Add override entry",
|
||||
"discardChanges": "Discard the changes?",
|
||||
"quickEdit": "Quick edit",
|
||||
"status": "Status",
|
||||
"statusDesc": "When disabled, the system DNS is used",
|
||||
"preferH3Desc": "Prefer HTTP/3 for DoH",
|
||||
"respectRules": "Respect rules",
|
||||
"respectRulesDesc": "DNS connections follow rules; requires proxy-server-nameserver",
|
||||
"respectRulesDesc": "DNS connections follow rules; requires Proxy Server Nameserver",
|
||||
"dnsMode": "DNS mode",
|
||||
"fakeipRange": "Fake-IP range",
|
||||
"fakeipFilter": "Fake-IP filter",
|
||||
"defaultNameserver": "Default nameserver",
|
||||
"defaultNameserverDesc": "Used to resolve DNS servers",
|
||||
"nameserver": "Nameserver",
|
||||
"nameserverDesc": "Used to resolve domains",
|
||||
"useHosts": "Use hosts",
|
||||
"useSystemHosts": "Use system hosts",
|
||||
"nameserverPolicy": "Nameserver policy",
|
||||
"nameserverPolicyDesc": "Specify the nameserver policy for matching domains",
|
||||
"proxyNameserver": "Proxy nameserver",
|
||||
"proxyNameserverDesc": "Used to resolve proxy node domains",
|
||||
"fallback": "Fallback",
|
||||
"fallbackDesc": "Usually an overseas DNS",
|
||||
"fallbackFilter": "Fallback filter",
|
||||
"geoipCode": "GeoIP code",
|
||||
"ipcidr": "IP/CIDR",
|
||||
"domain": "Domain",
|
||||
"listenRoutingMark": "Listen routing mark",
|
||||
"listenRoutingMarkDesc": "Linux only",
|
||||
"ipv6Timeout": "IPv6 timeout (ms)",
|
||||
"cacheAlgorithm": "Cache algorithm",
|
||||
"cacheMaxSize": "Cache size",
|
||||
"fakeipRange6": "Fake-IP range (IPv6)",
|
||||
"fakeipFilterMode": "Fake-IP filter mode",
|
||||
"fakeipFilterModeDesc": "blacklist excludes matches, whitelist fakes only matches, rule matches as rules",
|
||||
"fakeipTtl": "Fake-IP TTL",
|
||||
"reset": "Reset",
|
||||
"actionView": "Show/Hide",
|
||||
"actionStart": "Start/Stop",
|
||||
"actionMode": "Switch mode",
|
||||
"actionProxy": "System proxy",
|
||||
"actionTun": "TUN",
|
||||
"actionRuleMode": "Rule mode",
|
||||
"actionGlobalMode": "Global mode",
|
||||
"actionDirectMode": "Direct mode",
|
||||
"actionDelayTest": "Test all delays",
|
||||
"actionUpdateProfiles": "Update profiles",
|
||||
"disclaimer": "Disclaimer",
|
||||
"disclaimerDesc": "This software is intended only for non-commercial uses such as learning and research. Using it for any commercial purpose is strictly prohibited; any commercial activity is unrelated to this software.",
|
||||
"disclaimerDesc": "Before using FlClash (\"the Software\"), please read this statement carefully and make sure you understand all of it. Tapping \"Agree\" means you have read, understood, and accept every term below. If you do not agree, tap \"Exit\" and stop using the Software.",
|
||||
"disclaimerSoftwareTitle": "Nature of the software",
|
||||
"disclaimerSoftwareContent": "The Software is an open-source network proxy client built on the Clash.Meta (mihomo) core. It only provides local tooling such as configuration management, rule-based routing, and traffic forwarding.\n\nThe Software itself does not provide any proxy server, node, subscription, or network access service, and has no partnership, agency, or guarantee relationship with any provider of such services.",
|
||||
"disclaimerUsageTitle": "Restrictions on use",
|
||||
"disclaimerUsageContent": "The Software is intended only for non-commercial uses such as learning, exchange, and technical research. Using it for any commercial purpose is strictly prohibited, including but not limited to paid distribution, bundled sales, use as part of a commercial service, or doing business in the name of the Software. Any commercial activity is unrelated to the Software and its developers.\n\nUsing the Software for anything that violates the laws and regulations of your country or region is strictly prohibited, including but not limited to bypassing lawfully imposed network access restrictions, spreading illegal content, launching network attacks, or infringing the lawful rights of others.",
|
||||
"disclaimerResponsibilityTitle": "Your responsibility",
|
||||
"disclaimerResponsibilityContent": "You are responsible for making sure that using the Software is lawful where you live, and you alone bear the legal responsibility for everything you do with it and its consequences.\n\nThe subscriptions, nodes, and configurations you import are your own choice. Whether their source is lawful, their content is safe, and their service is reliable is a matter between you and their providers.",
|
||||
"disclaimerThirdPartyTitle": "Third-party content",
|
||||
"disclaimerThirdPartyContent": "Subscription links, configuration files, rule sets, scripts, external resources, and external links are all provided by third parties. The developers cannot and do not review or guarantee their legality, accuracy, security, or availability.\n\nAny data leak, financial loss, account ban, or other loss caused by third-party content is to be settled between you and the third party; the developers bear no responsibility for it.",
|
||||
"disclaimerWarrantyTitle": "No warranty",
|
||||
"disclaimerWarrantyContent": "The Software is provided \"as is\" and \"as available\", without warranty of any kind, express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, uninterrupted availability, freedom from errors, or freedom from security vulnerabilities.\n\nThe developers do not guarantee that the Software will meet your needs or that it will run without interruption or error.",
|
||||
"disclaimerLiabilityTitle": "Limitation of liability",
|
||||
"disclaimerLiabilityContent": "To the maximum extent permitted by applicable law, neither the developers nor any contributor shall be liable for any direct, indirect, incidental, special, punitive, or consequential damages arising from the use of or inability to use the Software, including but not limited to data loss, device damage, network failures, business interruption, lost profits, or any resulting legal dispute, even if advised of the possibility of such damages.",
|
||||
"disclaimerPrivacyTitle": "Data collection and privacy",
|
||||
"disclaimerPrivacyContent": "The Software does not collect or upload your subscription URLs, node details, configuration content, visited websites, connection records, traffic content, or logs. This data stays on your device and the developers have no access to it.\n\nThe Software only reaches the network when you use a feature that needs it, such as fetching the subscription URL you provided when updating a profile, or contacting GitHub when checking for updates.\n\nThe desktop versions (Windows, macOS, Linux) include no analytics or crash reporting service. The Android version includes the following two Google Firebase services to improve stability:",
|
||||
"disclaimerCrashlyticsTitle": "Firebase Crashlytics (crash analytics)",
|
||||
"disclaimerCrashlyticsContent": "When the app crashes, a crash report is uploaded automatically.\n\nWhat is collected: the crash stack trace and error message, the time of the crash, the app version and build number, the device brand and model, the Android version, screen orientation, free memory and storage, whether the device is rooted, and a random installation ID that is created on install and reset on reinstall.\n\nPurpose: only to locate and fix crashes.\n\nYou can turn it off at any time under \"Tools > General > Crash analytics\".",
|
||||
"disclaimerAnalyticsTitle": "Firebase Analytics (usage statistics)",
|
||||
"disclaimerAnalyticsContent": "Basic app usage statistics are collected automatically with Firebase.\n\nWhat is collected: basic events such as first launch, app opens and session length, and app updates; an app instance ID; the device model, OS version, and system language; and an approximate country- or region-level location inferred from the IP address.\n\nPurpose: only to understand the number of active devices, version distribution, and OS compatibility. The developers do not use this data for advertising, do not sell it, and do not link it to your subscriptions or configurations.",
|
||||
"disclaimerDataProcessingContent": "This data is processed and stored by Google on our behalf, may be transferred to servers outside your country or region (such as in the United States), and is governed by the Google Privacy Policy and the Firebase privacy and security documentation. Crash reports are kept for up to 90 days; statistics are kept under the Firebase default retention policy.",
|
||||
"disclaimerLicenseTitle": "Open-source license",
|
||||
"disclaimerLicenseContent": "The Software is open source under the GPL-3.0 license. You may use, modify, and distribute it freely as long as you comply with that license, which requires derivative works to be released under GPL-3.0 as well and the original copyright notices to be kept.\n\nThird-party components in the Software, including the Clash.Meta core, follow their own licenses. The original authors are not responsible for any issue arising from modified or redistributed versions.",
|
||||
"disclaimerChangesTitle": "Changes to this statement",
|
||||
"disclaimerChangesContent": "The developers may revise this statement with any release, and the revision takes effect when that release is published. Continuing to use the Software after updating means you accept the revised statement.",
|
||||
"disclaimerAcceptTitle": "Acceptance",
|
||||
"disclaimerAcceptContent": "By installing, copying, or using the Software, you are deemed to have read and agreed to this entire statement. If you disagree with any of its terms, stop using and uninstall the Software immediately.",
|
||||
"disclaimerAndroidOnly": "Android only",
|
||||
"disclaimerGooglePrivacy": "Google Privacy Policy",
|
||||
"disclaimerFirebasePrivacy": "Firebase privacy and security",
|
||||
"agree": "Agree",
|
||||
"hotkeyManagement": "Hotkey management",
|
||||
"hotkeyManagementDesc": "Control the app with the keyboard",
|
||||
"pressKeyboard": "Please press a key",
|
||||
"inputCorrectHotkey": "Please enter a valid hotkey",
|
||||
"hotkeyConflict": "Hotkey conflict",
|
||||
"hotkeyDesc": "Global hotkeys work even while the window is hidden. Tap an action to record its key combination.",
|
||||
"hotkeyNotSet": "Not set",
|
||||
"pressKeyboard": "Press a key combination",
|
||||
"hotkeyNeedsModifier": "Include at least one of {modifiers}",
|
||||
"hotkeyConflictWith": "Already used by “{action}”. Saving moves it here.",
|
||||
"hotkeyUnavailable": "Not registered, it may be taken by another app",
|
||||
"remove": "Remove",
|
||||
"noHotKey": "No hotkeys yet",
|
||||
"noNetwork": "No network",
|
||||
"ipv6InboundDesc": "Allow IPv6 inbound",
|
||||
"exportLogs": "Export logs",
|
||||
"exportSuccess": "Export successful",
|
||||
"iconStyle": "Icon style",
|
||||
"onlyIcon": "Icon only",
|
||||
"iconStyleFilled": "Filled",
|
||||
"iconStylePlain": "Plain",
|
||||
"iconStyleHidden": "Hidden",
|
||||
"filter": "Filter",
|
||||
"hideTimeoutProxies": "Hide timed-out nodes",
|
||||
"hideTimeoutProxiesDesc": "Leave out nodes whose last delay test timed out",
|
||||
"stackMode": "Stack mode",
|
||||
"network": "Network",
|
||||
"networkDesc": "Adjust network-related settings",
|
||||
"bypassDomain": "Bypass domains",
|
||||
"bypassDomainDesc": "Only takes effect while the system proxy is enabled",
|
||||
"resetTip": "Are you sure you want to reset?",
|
||||
@@ -284,7 +342,6 @@
|
||||
"routeModeBypassPrivate": "Bypass private addresses",
|
||||
"routeModeConfig": "Use config",
|
||||
"routeAddress": "Route addresses",
|
||||
"routeAddressDesc": "Configure the listened route addresses",
|
||||
"interfaceNameMode": "Outbound interface",
|
||||
"interfaceNameModeClear": "Clear",
|
||||
"interfaceNameModeFollow": "Follow config",
|
||||
@@ -293,6 +350,20 @@
|
||||
"interfaceNameDesc": "Network interface used for outbound connections",
|
||||
"copyEnvVar": "Copy environment variables",
|
||||
"memoryInfo": "Memory info",
|
||||
"total": "Total",
|
||||
"releaseMemory": "Release memory",
|
||||
"memoryCoreHeapInuse": "Heap in use",
|
||||
"memoryCoreHeapIdle": "Heap idle",
|
||||
"memoryCoreStack": "Goroutine stacks",
|
||||
"memoryCoreRuntime": "Runtime overhead",
|
||||
"memoryCoreNotRunning": "Core is not running",
|
||||
"memoryAppResident": "Resident memory",
|
||||
"memoryAppShared": "App & shared",
|
||||
"memoryEstimateDesc": "Estimated from process resident memory; it may differ from what the system reports.",
|
||||
"memoryEstimateSharedDesc": "The Core runs inside the app process. Its share is estimated from runtime stats, and the rest counts as app and shared memory.",
|
||||
"memoryReleased": "Memory released",
|
||||
"memoryReleasedSize": "Released {size}",
|
||||
"releaseMemoryFailed": "Failed to release memory",
|
||||
"cancel": "Cancel",
|
||||
"fileIsUpdate": "The file has been modified. Save the changes?",
|
||||
"profileHasUpdate": "The profile has been modified. Turn off auto update?",
|
||||
@@ -305,27 +376,27 @@
|
||||
"listen": "Listen",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"lineWrap": "Word wrap",
|
||||
"none": "None",
|
||||
"basicConfig": "Basic configuration",
|
||||
"basicConfigDesc": "Modify the basic configuration globally",
|
||||
"advancedConfig": "Advanced configuration",
|
||||
"advancedConfigDesc": "Provides diverse configuration options",
|
||||
"advancedConfigDesc": "Network, DNS, added rules, and scripts",
|
||||
"selectedCountTitle": "{count} selected",
|
||||
"addRule": "Add rule",
|
||||
"ruleName": "Rule name",
|
||||
"content": "Content",
|
||||
"subRule": "Sub-rule",
|
||||
"ruleTarget": "Rule target",
|
||||
"matchTargetTitle": "Match target",
|
||||
"matchTarget": "MATCH-TARGET",
|
||||
"followProfile": "Follow profile",
|
||||
"matchTargetDesc": "Where rules targeting MATCH-TARGET go. Defaults to the target of the final MATCH rule in this profile.",
|
||||
"selectMatchTarget": "Select MATCH-TARGET",
|
||||
"sourceIp": "Source IP",
|
||||
"noResolve": "Don't resolve IP",
|
||||
"saveChanges": "Save the changes?",
|
||||
"findProcessModeDesc": "Enabling causes some performance loss",
|
||||
"tabAnimationDesc": "Only effective in mobile view",
|
||||
"navigationBarStyle": "Bottom bar",
|
||||
"floating": "Floating",
|
||||
"docked": "Docked",
|
||||
"slide": "Slide",
|
||||
"fade": "Fade",
|
||||
"pureBlack": "Pure black",
|
||||
"colorSchemes": "Color schemes",
|
||||
"palette": "Palette",
|
||||
"tonalSpotScheme": "Tonal spot",
|
||||
@@ -348,6 +419,8 @@
|
||||
"launchInterruptedTip": "The app exited unexpectedly while it was starting up last time. Automatic setup was skipped for this launch; you can start it manually to retry.",
|
||||
"clearData": "Clear data",
|
||||
"textScale": "Text scaling",
|
||||
"textScalePreview": "Text in the app will look like this",
|
||||
"followSystem": "Follow system",
|
||||
"internet": "Internet",
|
||||
"systemApp": "System apps",
|
||||
"noNetworkApp": "No-network apps",
|
||||
@@ -361,7 +434,13 @@
|
||||
"interval": "Interval",
|
||||
"existsTip": "{label} already exists",
|
||||
"maxLengthTip": "{label} must be at most {max} characters",
|
||||
"multipleValuesTip": "Separate multiple values with commas",
|
||||
"singleAdd": "Single add",
|
||||
"batchAdd": "Batch add",
|
||||
"batchListInputTip": "One item per line, or separated by commas",
|
||||
"batchMapInputTip": "One entry per line: key, a space, then value",
|
||||
"batchPreviewTip": "{count} to add, {skipped} skipped as existing",
|
||||
"lineIssueTip": "Line {line}: {message}",
|
||||
"singleValueTip": "{label} must be a single item",
|
||||
"deleteTip": "Are you sure you want to delete this {label}?",
|
||||
"deleteMultipTip": "Are you sure you want to delete the selected {label}?",
|
||||
"nullTip": "No {label} yet",
|
||||
@@ -378,6 +457,7 @@
|
||||
"import": "Import",
|
||||
"importFile": "Import from file",
|
||||
"importUrl": "Import from URL",
|
||||
"startFromScratch": "Start from scratch",
|
||||
"autoSetSystemDns": "Auto-set system DNS",
|
||||
"details": "{label} details",
|
||||
"creationTime": "Creation time",
|
||||
@@ -396,7 +476,7 @@
|
||||
"request": "Request",
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected",
|
||||
"connecting": "Connecting...",
|
||||
"connecting": "Connecting…",
|
||||
"restartCoreTip": "Are you sure you want to restart the core?",
|
||||
"forceRestartCoreTip": "Are you sure you want to force restart the core?",
|
||||
"dnsHijacking": "DNS hijacking",
|
||||
@@ -406,20 +486,25 @@
|
||||
"crashlytics": "Crash analytics",
|
||||
"crashlyticsTip": "When enabled, crash logs without sensitive information are uploaded automatically when the app crashes",
|
||||
"appendSystemDns": "Append system DNS",
|
||||
"appendSystemDnsTip": "Force-append the system DNS to the configuration",
|
||||
"editRule": "Edit rule",
|
||||
"overrideMode": "Override mode",
|
||||
"standardModeDesc": "Standard mode: overrides the basic configuration and offers simple rule additions",
|
||||
"scriptModeDesc": "Script mode: uses external extension scripts to override the configuration in one click",
|
||||
"addedRules": "Added rules",
|
||||
"controlGlobalAddedRules": "Control global added rules",
|
||||
"quickAdd": "Quick add",
|
||||
"rulePresetBlockQuic": "Block QUIC",
|
||||
"rulePresetBlockStun": "Block STUN",
|
||||
"rulePresetBlockDot": "Block DNS over TLS",
|
||||
"rulePresetSystemServicesDirect": "Apple and Microsoft direct",
|
||||
"rulePresetBittorrentDirect": "BitTorrent direct",
|
||||
"rulePresetLanDirect": "LAN direct",
|
||||
"overrideScript": "Override script",
|
||||
"goToConfigureScript": "Go to script configuration",
|
||||
"editGlobalRules": "Edit global rules",
|
||||
"externalFetch": "External fetch",
|
||||
"confirmForceCrashCore": "Are you sure you want to force crash the core?",
|
||||
"confirmClearAllData": "Are you sure you want to clear all data?",
|
||||
"loading": "Loading...",
|
||||
"loading": "Loading…",
|
||||
"yearsAgo": "{count, plural, =1{1 year ago} other{{count} years ago}}",
|
||||
"monthsAgo": "{count, plural, =1{1 month ago} other{{count} months ago}}",
|
||||
"daysAgo": "{count, plural, =1{1 day ago} other{{count} days ago}}",
|
||||
@@ -435,24 +520,31 @@
|
||||
"speedStatistics": "Speed statistics",
|
||||
"resetPageChangesTip": "This page has changes. Are you sure you want to reset?",
|
||||
"overwriteTypeCustom": "Custom",
|
||||
"overwriteTypeCustomDesc": "Custom mode: fully customize proxy groups and rules",
|
||||
"overwriteTypeCustomDesc": "Custom mode: fully customize proxies, proxy groups and rules",
|
||||
"unknownNetworkError": "Unknown network error",
|
||||
"restoreException": "Restore error",
|
||||
"networkException": "Network error, please check your connection and try again",
|
||||
"backupFromNewerVersion": "This backup comes from a newer version of the app. Update the app before restoring it",
|
||||
"networkTimeoutError": "The request timed out. Check your network or proxy, then try again",
|
||||
"networkHostLookupError": "Couldn't resolve the server address. Check that the URL is correct and DNS is working",
|
||||
"networkConnectionError": "Couldn't connect to the server. Check your network connection or proxy settings",
|
||||
"networkTlsError": "Secure connection failed. The server's certificate may be invalid, or the connection is being intercepted",
|
||||
"networkCancelledError": "The request was cancelled",
|
||||
"networkAccessDeniedError": "The server denied access (HTTP {code}). The link may have expired, or the credentials are wrong",
|
||||
"networkNotFoundError": "Nothing was found at this address (HTTP {code}). Check that the URL is correct",
|
||||
"networkRateLimitedError": "Too many requests (HTTP 429). Wait a moment and try again",
|
||||
"networkServerError": "The server ran into a problem (HTTP {code}). Try again later",
|
||||
"networkBadResponseError": "The server rejected the request (HTTP {code})",
|
||||
"networkRequestFailed": "Network request failed: {detail}",
|
||||
"invalidBackupFile": "Invalid backup file",
|
||||
"pruneCache": "Prune cache",
|
||||
"backupAndRestore": "Backup and restore",
|
||||
"backupAndRestoreDesc": "Sync data via WebDAV or files",
|
||||
"restore": "Restore",
|
||||
"restoreSuccess": "Restore successful",
|
||||
"restoreFromWebDAVDesc": "Restore data from WebDAV",
|
||||
"restoreFromFileDesc": "Restore data from a file",
|
||||
"restoreOnlyConfig": "Restore profiles only",
|
||||
"restoreAllData": "Restore all data",
|
||||
"addProfile": "Add profile",
|
||||
"delayTest": "Delay test",
|
||||
"proxyGroupEmpty": "Proxy group is empty",
|
||||
"proxyGroupNameEmpty": "Proxy group name cannot be empty",
|
||||
"proxyGroupNameDuplicate": "Duplicate proxy group name",
|
||||
"confirmExitWindow": "Are you sure you want to exit the current window?",
|
||||
"dataChangedSave": "Data changes detected. Save them?",
|
||||
@@ -464,6 +556,8 @@
|
||||
"excludeProxyFilter": "Exclude proxy filter",
|
||||
"excludeType": "Exclude type",
|
||||
"expectedStatus": "Expected status",
|
||||
"strategy": "Strategy",
|
||||
"tolerance": "Tolerance",
|
||||
"selectProxies": "Select proxies",
|
||||
"inputProxyGroupName": "Enter the proxy group name",
|
||||
"helperCorruptTip": "Helper service unavailable; TUN mode cannot be enabled. Reinstall FlClash to restore it.",
|
||||
@@ -488,7 +582,7 @@
|
||||
"basicStrategy": "Basic strategies",
|
||||
"editProxy": "Edit proxy",
|
||||
"includeAllProxyProviders": "Include all proxy providers",
|
||||
"includeAllProxyProvidersTip": "When enabled, the imported proxy providers are overridden",
|
||||
"includeAllProxyProvidersTip": "When enabled, the group takes every proxy provider of this profile: the subscription's own, plus the profiles and app proxy providers any proxy group uses",
|
||||
"addProxyProviders": "Add proxy providers",
|
||||
"includeAllProxies": "Include all proxies",
|
||||
"includeAllProxiesTip": "Imports all proxies outside proxy groups; extra proxy groups can be added below",
|
||||
@@ -550,11 +644,11 @@
|
||||
"invalidSubRule": "{subRule} is an invalid SUB_RULE",
|
||||
"invalidPolicy": "{target} is an invalid policy",
|
||||
"invalidProxyProvider": "{providerName} is an invalid proxy provider",
|
||||
"invalidRuleSet": "{ruleSet} is an invalid rule set",
|
||||
"invalidNetworkContent": "Only tcp or udp is supported",
|
||||
"invalidRangeContent": "Enter numbers or ranges such as 80 or 8000-9000, separated by /",
|
||||
"invalidDscpContent": "A DSCP mark cannot exceed 63",
|
||||
"invalidProxy": "{proxyName} is an invalid proxy",
|
||||
"proxyGroupDetectedAbnormal": "The current proxy group is abnormal",
|
||||
"proxyProviderDetectedAbnormal": "The selected proxy providers are abnormal",
|
||||
"proxyDetectedAbnormal": "The selected proxies are abnormal",
|
||||
"createProfile": "Create profile",
|
||||
"locationPermissionRequired": "Location permission required",
|
||||
"locationPermissionGuide": "1. Open System Settings > Privacy & Security\n2. Choose Location Services\n3. Find and check {appName} in the list\n\nWhen you are done, return to the app to continue. Thank you for your cooperation.",
|
||||
"prerequisites": "Prerequisites",
|
||||
@@ -573,7 +667,7 @@
|
||||
"editSsid": "Edit SSID",
|
||||
"authorized": "Authorized",
|
||||
"tapToAuthorize": "Tap to authorize",
|
||||
"suspended": "Suspended...",
|
||||
"suspended": "Suspended…",
|
||||
"geoOptions": "Geo options",
|
||||
"geoAutoUpdate": "Auto update",
|
||||
"geoAutoUpdateInterval": "Auto-update interval",
|
||||
@@ -584,7 +678,6 @@
|
||||
"geoSkipped": "{name} is already up to date",
|
||||
"geoUpdated": "{name} updated",
|
||||
"secondsCount": "{count, plural, =1{1 second} other{{count} seconds}}",
|
||||
"entriesCount": "{count, plural, =1{1 entry} other{{count} entries}}",
|
||||
"proxiesCount": "{count, plural, =1{1 proxy} other{{count} proxies}}",
|
||||
"rulesCount": "{count, plural, =1{1 rule} other{{count} rules}}",
|
||||
"changelogBreaking": "Breaking changes",
|
||||
@@ -600,7 +693,6 @@
|
||||
"pinWindow": "Pin window",
|
||||
"unpinWindow": "Unpin window",
|
||||
"exitFullScreen": "Exit full screen",
|
||||
"toggleLabel": "Toggle labels",
|
||||
"torch": "Flashlight",
|
||||
"pickFromAlbum": "Choose from album",
|
||||
"blockConnection": "Block connection",
|
||||
@@ -611,11 +703,79 @@
|
||||
"previousMatch": "Previous match",
|
||||
"nextMatch": "Next match",
|
||||
"clearSearch": "Clear search",
|
||||
"noSearchResults": "No matching results",
|
||||
"addWidget": "Add widget",
|
||||
"showPassword": "Show password",
|
||||
"hidePassword": "Hide password",
|
||||
"authorize": "Authorize",
|
||||
"installedAppsPermissionRequired": "App list permission required",
|
||||
"installedAppsPermissionDesc": "This system hides the installed app list until the permission is granted. Authorize it to configure the per-app proxy.",
|
||||
"installedAppsPermissionDeniedMessage": "The app list permission was denied, so installed apps cannot be listed. Please grant it manually in system settings."
|
||||
"installedAppsPermissionDeniedMessage": "The app list permission was denied, so installed apps cannot be listed. Please grant it manually in system settings.",
|
||||
"outboundIp": "Outbound IP",
|
||||
"ipAddress": "IP address",
|
||||
"ipQualityLevel": "Level",
|
||||
"ipQualityGood": "Good",
|
||||
"ipQualityNormal": "Normal",
|
||||
"ipQualityRisky": "Risky",
|
||||
"ipType": "Type",
|
||||
"ipTypeResidential": "Residential",
|
||||
"ipTypeMobile": "Mobile network",
|
||||
"ipTypeBusiness": "Business",
|
||||
"ipTypeHosting": "Data center",
|
||||
"ipFlags": "Flags",
|
||||
"ipFlagProxy": "Proxy",
|
||||
"ipFlagVpn": "VPN",
|
||||
"ipFlagTor": "Tor",
|
||||
"ipFlagAbuser": "Abuse history",
|
||||
"ipOrganization": "Organization",
|
||||
"ipAsn": "ASN",
|
||||
"ipQualitySource": "Answered by",
|
||||
"ipQualitySources": "Sources",
|
||||
"ipQualityFailed": "Couldn't determine the IP type",
|
||||
"ipQualityRetry": "Check again",
|
||||
"ipSourceNoType": "No type",
|
||||
"ipSourceRateLimited": "Rate limited",
|
||||
"ipSourceIpMismatch": "Different outbound IP",
|
||||
"hideIp": "Hide IP",
|
||||
"editorUnavailable": "Editor unavailable",
|
||||
"replace": "Replace",
|
||||
"replaceAll": "Replace all",
|
||||
"cameraPermissionRequired": "Camera permission required",
|
||||
"cameraPermissionDesc": "Allow camera access in system settings to scan QR codes, or choose a QR code image from the album.",
|
||||
"cameraUnavailable": "Camera unavailable",
|
||||
"retry": "Retry",
|
||||
"invalidProfileQrcode": "This QR code doesn't contain a profile link",
|
||||
"dnsQueries": "DNS queries",
|
||||
"recordType": "Record type",
|
||||
"answers": "Answers",
|
||||
"responseCode": "Response code",
|
||||
"error": "Error",
|
||||
"initiator": "Initiator",
|
||||
"cache": "Cache",
|
||||
"proxyNode": "Proxy node",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"nonTextProviderFile": "This external resource is not a text file",
|
||||
"addCustomProxy": "Add proxy",
|
||||
"proxyDefinition": "Full configuration",
|
||||
"proxyDefinitionNotMap": "The configuration must be a YAML mapping with a name and a type",
|
||||
"customProxiesEmpty": "No custom proxies, so the profile's own proxies are used",
|
||||
"overwriteIssueEmptyName": "The name is empty",
|
||||
"overwriteIssueReservedName": "{name} is a built-in policy name and cannot be used here",
|
||||
"overwriteIssueDuplicateName": "The name {name} is already used by another proxy or proxy group",
|
||||
"overwriteIssueCoreRejected": "The core cannot parse this proxy: {message}",
|
||||
"overwriteIssueMissingProxies": "These proxies or policies do not exist: {names}",
|
||||
"overwriteIssueMissingProviders": "These proxy providers do not exist: {names}",
|
||||
"overwriteIssueNoProxySource": "No proxies or proxy providers are selected, so the core rejects this group",
|
||||
"overwriteIssueGroupLoop": "Proxy groups reference each other in a loop: {path}",
|
||||
"overwriteIssuesSummary": "{count} items have problems, and applying this override may fail",
|
||||
"overwriteIssueSubscriptionGroupMissingProxies": "The profile's own proxy groups name proxies that the custom proxies no longer include: {names}",
|
||||
"providerInUse": "{label} is still used by the custom proxy groups or rules of {profiles}. Remove it there first",
|
||||
"providerSourceSubscription": "Subscription",
|
||||
"providerRenameShadowed": "The subscriptions of {profiles} already have {label}, so those profiles would switch to theirs. Choose another name",
|
||||
"fontSize": "Size",
|
||||
"large": "Large",
|
||||
"extraLarge": "Extra large",
|
||||
"switchProfile": "Switch profile",
|
||||
"lastUpdated": "Last updated"
|
||||
}
|
||||
|
||||
@@ -9,19 +9,32 @@
|
||||
"profiles": "プロファイル",
|
||||
"tools": "ツール",
|
||||
"logs": "ログ",
|
||||
"logsDesc": "キャプチャしたログの記録",
|
||||
"resources": "リソース",
|
||||
"resourcesDesc": "外部リソースの関連情報",
|
||||
"trafficUsage": "トラフィック統計",
|
||||
"networkSpeed": "ネットワーク速度",
|
||||
"outboundMode": "アウトバウンドモード",
|
||||
"networkDetection": "ネットワーク検出",
|
||||
"serviceStatus": "サービスの状態",
|
||||
"serviceRestricted": "アクセス制限",
|
||||
"serviceAvailable": "利用可能",
|
||||
"serviceUnavailable": "利用不可",
|
||||
"serviceFailed": "検出に失敗しました",
|
||||
"serviceDisallowedIsp": "許可されていない ISP",
|
||||
"serviceBlocked": "ブロック済み",
|
||||
"serviceUnsupportedRegion": "対象外の地域",
|
||||
"serviceOriginalsOnly": "オリジナル作品のみ",
|
||||
"serviceComingSoon": "近日提供予定",
|
||||
"servicePending": "未検査",
|
||||
"serviceManage": "サービスを管理",
|
||||
"serviceCheck": "検査",
|
||||
"serviceCheckAll": "すべて検査",
|
||||
"serviceCheckedAt": "{time} に検査",
|
||||
"upload": "アップロード",
|
||||
"download": "ダウンロード",
|
||||
"usedTraffic": "使用済みトラフィック",
|
||||
"totalTraffic": "合計トラフィック",
|
||||
"expireTime": "有効期限",
|
||||
"nullProfileDesc": "プロファイルがありません。先にプロファイルを追加してください",
|
||||
"nullProfileDesc": "プロファイルを追加して始めましょう",
|
||||
"settings": "設定",
|
||||
"language": "言語",
|
||||
"defaultText": "デフォルト",
|
||||
@@ -36,27 +49,22 @@
|
||||
"themeDesc": "ダークモードの設定と色の調整",
|
||||
"override": "上書き",
|
||||
"allowLan": "LANプロキシ",
|
||||
"allowLanDesc": "LAN経由でのプロキシ利用を許可します",
|
||||
"tun": "TUN",
|
||||
"tunDesc": "管理者モードでのみ有効",
|
||||
"minimizeOnExit": "終了時に最小化",
|
||||
"minimizeOnExitDesc": "システム標準の終了動作を変更します",
|
||||
"autoLaunch": "自動起動",
|
||||
"autoLaunchDesc": "システム起動時に自動的に起動します",
|
||||
"silentLaunch": "サイレント起動",
|
||||
"silentLaunchDesc": "バックグラウンドで起動します",
|
||||
"silentLaunchDesc": "起動時にウィンドウを表示しません",
|
||||
"autoRun": "自動実行",
|
||||
"autoRunDesc": "アプリを開いたときに自動的に実行します",
|
||||
"logcat": "ログキャプチャ",
|
||||
"logcatDesc": "無効にするとログの入り口が非表示になります",
|
||||
"autoCheckUpdate": "更新の自動チェック",
|
||||
"autoCheckUpdateDesc": "アプリ起動時に更新を自動的にチェックします",
|
||||
"checkCertificate": "TLS証明書を検証",
|
||||
"checkCertificateDesc": "信頼できない証明書を拒否します。無効にすると、サブスクリプションやバックアップが中間者攻撃にさらされます",
|
||||
"accessControl": "アクセス制御",
|
||||
"accessControlDesc": "プロキシを利用するアプリを設定します",
|
||||
"application": "アプリケーション",
|
||||
"applicationDesc": "アプリケーション関連の設定を変更します",
|
||||
"edit": "編集",
|
||||
"confirm": "OK",
|
||||
"update": "更新",
|
||||
@@ -103,12 +111,15 @@
|
||||
"show": "表示",
|
||||
"exit": "終了",
|
||||
"systemProxy": "システムプロキシ",
|
||||
"safeMode": "セーフモード",
|
||||
"safeModeAppTitle": "{appName}(セーフモード)",
|
||||
"project": "プロジェクト",
|
||||
"core": "コア",
|
||||
"tabAnimation": "タブアニメーション",
|
||||
"desc": "ClashMetaベースのマルチプラットフォーム対応プロキシクライアント。シンプルで使いやすく、オープンソースで広告もありません。",
|
||||
"startVpn": "VPNを起動しています...",
|
||||
"stopVpn": "VPNを停止しています...",
|
||||
"startVpn": "VPNを起動しています…",
|
||||
"stopVpn": "VPNを停止しています…",
|
||||
"localNetworkDeniedTip": "ローカルネットワークの権限が拒否されたため gvisor スタックを使用します。LAN にはアクセスできません。",
|
||||
"compatible": "互換モード",
|
||||
"notSelectedTip": "現在のプロキシグループは選択できません",
|
||||
"changeProxyFailedTip": "プロキシの切り替えに失敗したため、前回の選択に戻しました",
|
||||
@@ -137,32 +148,31 @@
|
||||
"country": "地域",
|
||||
"search": "検索",
|
||||
"allowBypass": "アプリによるVPNバイパスを許可",
|
||||
"allowBypassDesc": "有効にすると、一部のアプリがVPNをバイパスできます",
|
||||
"externalController": "外部コントローラー",
|
||||
"externalControllerDesc": "有効にすると、ポート9090でClashコアを制御できます",
|
||||
"ipv6Desc": "有効にすると、IPv6トラフィックを受信できます",
|
||||
"app": "アプリ",
|
||||
"general": "一般",
|
||||
"systemProxyDesc": "システムプロキシを設定します",
|
||||
"startupAndBackground": "起動とバックグラウンド",
|
||||
"requestsAndUpdates": "リクエストと更新",
|
||||
"logsAndDiagnostics": "ログと診断",
|
||||
"userAgent": "User-Agent",
|
||||
"unifiedDelay": "統一遅延",
|
||||
"unifiedDelayDesc": "ハンドシェイクなどの余分な遅延を除きます",
|
||||
"tcpConcurrent": "TCP同時接続",
|
||||
"tcpConcurrentDesc": "有効にすると、TCPの同時接続を許可します",
|
||||
"geodataLoader": "Geo低メモリモード",
|
||||
"geodataLoaderDesc": "有効にすると、低メモリのGeoローダーを使用します",
|
||||
"requests": "リクエスト",
|
||||
"requestsDesc": "最近のリクエスト記録を表示します",
|
||||
"recentRequests": "最近のリクエスト",
|
||||
"findProcessMode": "プロセス検出",
|
||||
"init": "初期化",
|
||||
"infiniteTime": "無期限",
|
||||
"connections": "接続",
|
||||
"liveConnections": "リアルタイム接続",
|
||||
"inbound": "インバウンド",
|
||||
"connectionsDesc": "現在の接続データを表示します",
|
||||
"intranetIP": "イントラネットIP",
|
||||
"view": "表示",
|
||||
"cut": "切り取り",
|
||||
"copy": "コピー",
|
||||
"clipboardWriteFailed": "クリップボードにコピーできませんでした。選択範囲が大きすぎる可能性があります",
|
||||
"paste": "貼り付け",
|
||||
"testUrl": "テストURL",
|
||||
"sync": "同期",
|
||||
@@ -182,19 +192,18 @@
|
||||
"go": "開く",
|
||||
"externalLink": "外部リンク",
|
||||
"otherContributors": "その他の貢献者",
|
||||
"appIconDesign": "アプリアイコンのデザイン",
|
||||
"autoCloseConnections": "接続を自動的に閉じる",
|
||||
"autoCloseConnectionsDesc": "ノードの切り替え後、接続を自動的に閉じます",
|
||||
"onlyStatisticsProxy": "プロキシのみ集計",
|
||||
"onlyStatisticsProxyDesc": "有効にすると、プロキシのトラフィックのみを集計します",
|
||||
"onlyStatisticsProxy": "プロキシトラフィックのみ集計",
|
||||
"showNotificationStopAction": "通知に停止ボタンを表示",
|
||||
"showNotificationStopActionDesc": "常駐通知に停止ボタンを表示します。これが原因で通知が常に展開される場合はオフにしてください",
|
||||
"pureBlackMode": "ピュアブラックモード",
|
||||
"sidebarBlur": "サイドバーのぼかし",
|
||||
"sidebarBlurDesc": "ウィンドウ背後のデスクトップをぼかしてサイドバーに透過します",
|
||||
"keepAliveIntervalDesc": "TCPキープアライブ間隔",
|
||||
"entries": " 件",
|
||||
"local": "ローカル",
|
||||
"remote": "リモート",
|
||||
"remoteBackupDesc": "WebDAVにデータをバックアップします",
|
||||
"localBackupDesc": "ローカルにデータをバックアップします",
|
||||
"mode": "モード",
|
||||
"time": "時刻",
|
||||
"source": "ソース",
|
||||
@@ -208,70 +217,119 @@
|
||||
"loose": "ゆったり",
|
||||
"profilesSort": "プロファイルの並べ替え",
|
||||
"start": "開始",
|
||||
"runTime": "起動時間",
|
||||
"stop": "停止",
|
||||
"dnsDesc": "DNS関連の設定を更新します",
|
||||
"key": "キー",
|
||||
"value": "値",
|
||||
"hostsDesc": "Hostsを追加します",
|
||||
"vpnTip": "変更はVPNの再起動後に有効になります",
|
||||
"vpnEnableDesc": "VpnServiceでシステムの全トラフィックを自動的にルーティングします",
|
||||
"options": "オプション",
|
||||
"loopback": "ループバック解除ツール",
|
||||
"loopbackDesc": "UWPのループバック解除に使用します",
|
||||
"enabled": "有効",
|
||||
"disabled": "無効",
|
||||
"loopback": "UWP ループバック解除",
|
||||
"providers": "外部リソース",
|
||||
"proxyProviders": "プロキシプロバイダー",
|
||||
"ruleProviders": "ルールプロバイダー",
|
||||
"behavior": "動作",
|
||||
"format": "形式",
|
||||
"providerUrlTip": "リモートリソースのみ対応しています",
|
||||
"subscriptionInfo": "サブスクリプション情報",
|
||||
"overrideDns": "DNSを上書き",
|
||||
"overrideDnsDesc": "有効にすると、プロファイル内のDNS設定を上書きします",
|
||||
"overrideNtp": "NTPを上書き",
|
||||
"ntpStatusDesc": "システムクロックではなくNTPサーバーから時刻を取得します",
|
||||
"server": "サーバー",
|
||||
"ntpInterval": "同期間隔(分)",
|
||||
"dialerProxy": "ダイヤラープロキシ",
|
||||
"dialerProxyDesc": "NTPサーバーへの接続に使用するアウトバウンド",
|
||||
"writeToSystem": "システムに書き込む",
|
||||
"writeToSystemDesc": "システムクロックも設定します。Androidでは無視されます",
|
||||
"overrideEntries": "上書き項目",
|
||||
"addOverrideEntry": "上書き項目を追加",
|
||||
"discardChanges": "変更を破棄しますか?",
|
||||
"quickEdit": "クイック編集",
|
||||
"status": "状態",
|
||||
"statusDesc": "無効にすると、システムDNSを使用します",
|
||||
"preferH3Desc": "DoHでHTTP/3を優先します",
|
||||
"respectRules": "ルールに従う",
|
||||
"respectRulesDesc": "DNS接続がルールに従います。proxy-server-nameserverの設定が必要です",
|
||||
"respectRulesDesc": "DNS接続がルールに従います。Proxy Server Nameserverの設定が必要です",
|
||||
"dnsMode": "DNSモード",
|
||||
"fakeipRange": "Fake-IP範囲",
|
||||
"fakeipFilter": "Fake-IPフィルター",
|
||||
"defaultNameserver": "デフォルトネームサーバー",
|
||||
"defaultNameserverDesc": "DNSサーバーの名前解決に使用します",
|
||||
"nameserver": "ネームサーバー",
|
||||
"nameserverDesc": "ドメインの名前解決に使用します",
|
||||
"useHosts": "Hostsを使用",
|
||||
"useSystemHosts": "システムのHostsを使用",
|
||||
"nameserverPolicy": "ネームサーバーポリシー",
|
||||
"nameserverPolicyDesc": "ドメインごとのネームサーバーポリシーを指定します",
|
||||
"proxyNameserver": "プロキシネームサーバー",
|
||||
"proxyNameserverDesc": "プロキシノードのドメイン解決に使用します",
|
||||
"fallback": "フォールバック",
|
||||
"fallbackDesc": "通常は国外のDNSを使用します",
|
||||
"fallbackFilter": "フォールバックフィルター",
|
||||
"geoipCode": "GeoIPコード",
|
||||
"ipcidr": "IP/CIDR",
|
||||
"domain": "ドメイン",
|
||||
"listenRoutingMark": "リッスンのルーティングマーク",
|
||||
"listenRoutingMarkDesc": "Linuxのみ",
|
||||
"ipv6Timeout": "IPv6タイムアウト(ms)",
|
||||
"cacheAlgorithm": "キャッシュアルゴリズム",
|
||||
"cacheMaxSize": "キャッシュサイズ",
|
||||
"fakeipRange6": "Fake-IP範囲(IPv6)",
|
||||
"fakeipFilterMode": "Fake-IPフィルターモード",
|
||||
"fakeipFilterModeDesc": "blacklistは一致を除外、whitelistは一致のみ、ruleはルールで判定",
|
||||
"fakeipTtl": "Fake-IP TTL",
|
||||
"reset": "リセット",
|
||||
"actionView": "表示/非表示",
|
||||
"actionStart": "開始/停止",
|
||||
"actionMode": "モード切替",
|
||||
"actionProxy": "システムプロキシ",
|
||||
"actionTun": "TUN",
|
||||
"actionRuleMode": "ルールモード",
|
||||
"actionGlobalMode": "グローバルモード",
|
||||
"actionDirectMode": "ダイレクトモード",
|
||||
"actionDelayTest": "すべての遅延をテスト",
|
||||
"actionUpdateProfiles": "プロファイルを更新",
|
||||
"disclaimer": "免責事項",
|
||||
"disclaimerDesc": "本ソフトウェアは、学習・交流や研究などの非商用目的でのみ使用できます。商用目的での使用は固く禁じられています。いかなる商業行為も本ソフトウェアとは一切関係ありません。",
|
||||
"disclaimerDesc": "FlClash(以下「本ソフトウェア」)をご利用になる前に、本声明の内容をよくお読みになり、十分にご理解ください。「同意する」をタップすると、以下のすべての条項を読み、理解し、承諾したものとみなされます。同意いただけない場合は「終了」をタップし、本ソフトウェアの使用を中止してください。",
|
||||
"disclaimerSoftwareTitle": "ソフトウェアの性質",
|
||||
"disclaimerSoftwareContent": "本ソフトウェアは Clash.Meta(mihomo)コアをベースとしたオープンソースのネットワークプロキシクライアントであり、設定管理、ルールによる振り分け、トラフィック転送などのローカルツール機能のみを提供します。\n\n本ソフトウェア自体は、プロキシサーバー、ノード、サブスクリプション、ネットワーク接続サービスを一切提供しておらず、それらのサービス提供者と提携、代理、保証の関係はありません。",
|
||||
"disclaimerUsageTitle": "使用の制限",
|
||||
"disclaimerUsageContent": "本ソフトウェアは学習、交流、技術研究などの非商用目的でのみ使用できます。有償配布、抱き合わせ販売、商用サービスの一部としての利用、本ソフトウェアの名義での事業活動など、あらゆる商用目的での使用を固く禁じます。いかなる商業行為も本ソフトウェアおよび開発者とは一切関係ありません。\n\nお住まいの国または地域の法令に違反する目的での使用を固く禁じます。これには、法に基づくネットワークアクセス制限の回避、違法情報の拡散、サイバー攻撃、他者の権利の侵害などが含まれますが、これらに限りません。",
|
||||
"disclaimerResponsibilityTitle": "利用者の責任",
|
||||
"disclaimerResponsibilityContent": "お住まいの国または地域で本ソフトウェアの使用が合法であることはご自身で確認する必要があり、本ソフトウェアを使用したすべての行為とその結果について、ご自身が単独で法的責任を負います。\n\nインポートするサブスクリプション、ノード、設定はご自身の判断で選択したものです。その出所の適法性、内容の安全性、サービスの安定性については、利用者と各提供者の間で解決してください。",
|
||||
"disclaimerThirdPartyTitle": "サードパーティのコンテンツ",
|
||||
"disclaimerThirdPartyContent": "サブスクリプションリンク、設定ファイル、ルールセット、スクリプト、外部リソース、外部リンクはすべて第三者が提供するものであり、開発者はその適法性、正確性、安全性、可用性を審査または保証することはできず、また行いません。\n\nサードパーティのコンテンツの使用に起因する情報漏えい、財産上の損失、アカウント停止その他の損失は、利用者と第三者の間で解決するものとし、開発者は一切責任を負いません。",
|
||||
"disclaimerWarrantyTitle": "無保証",
|
||||
"disclaimerWarrantyContent": "本ソフトウェアは「現状のまま」かつ「提供可能な範囲で」提供され、商品性、特定目的への適合性、非侵害、継続的な可用性、エラーやセキュリティ上の脆弱性がないことの保証を含め、明示または黙示を問わずいかなる保証も伴いません。\n\n開発者は、本ソフトウェアが利用者の要件を満たすこと、また中断やエラーなく動作することを保証しません。",
|
||||
"disclaimerLiabilityTitle": "責任の制限",
|
||||
"disclaimerLiabilityContent": "適用法で認められる最大限の範囲において、開発者およびすべての貢献者は、本ソフトウェアの使用または使用不能に起因する直接的、間接的、偶発的、特別、懲罰的または結果的な損害(データの消失、機器の損傷、ネットワーク障害、業務の中断、逸失利益、およびそれに起因する法的紛争を含むがこれらに限らない)について、その可能性を知らされていた場合であっても、一切責任を負いません。",
|
||||
"disclaimerPrivacyTitle": "データ収集とプライバシー",
|
||||
"disclaimerPrivacyContent": "本ソフトウェアは、サブスクリプション URL、ノード情報、設定内容、閲覧したウェブサイト、接続記録、通信内容、ログを収集またはアップロードしません。これらのデータは端末内にのみ保存され、開発者がアクセスすることはできません。\n\n本ソフトウェアが外部ネットワークにアクセスするのは、プロファイル更新時に指定されたサブスクリプション URL へアクセスする場合や、アップデート確認時に GitHub へアクセスする場合など、該当する機能を使用したときのみです。\n\nデスクトップ版(Windows、macOS、Linux)には、統計やクラッシュレポートのサービスは一切組み込まれていません。Android 版には、安定性向上のため以下の 2 つの Google Firebase サービスが組み込まれています。",
|
||||
"disclaimerCrashlyticsTitle": "Firebase Crashlytics(クラッシュ分析)",
|
||||
"disclaimerCrashlyticsContent": "アプリがクラッシュした際に、クラッシュレポートを自動的に送信します。\n\n収集内容:クラッシュのスタックトレースとエラー情報、発生日時、アプリのバージョンとビルド番号、端末のメーカーとモデル、Android のバージョン、画面の向き、空きメモリと空きストレージ、root 化の有無、およびインストール時に生成され再インストールでリセットされるランダムなインストール ID。\n\n目的:クラッシュの特定と修正のためにのみ使用します。\n\n「ツール > 一般 > クラッシュ分析」からいつでもオフにできます。",
|
||||
"disclaimerAnalyticsTitle": "Firebase Analytics(利用統計)",
|
||||
"disclaimerAnalyticsContent": "Firebase により基本的なアプリ利用統計が自動的に収集されます。\n\n収集内容:初回起動、アプリの起動とセッション時間、アプリの更新などの基本イベント、アプリインスタンス ID、端末のモデル、OS のバージョン、システム言語、および IP アドレスから推定される国または地域レベルのおおよその位置情報。\n\n目的:アクティブな端末数、バージョン分布、OS の互換性を把握するためにのみ使用します。開発者はこれらのデータを広告に使用したり、販売したり、サブスクリプションや設定と関連付けたりすることはありません。",
|
||||
"disclaimerDataProcessingContent": "これらのデータは Google が代わりに処理・保存し、お住まいの国または地域外(米国など)のサーバーに転送される場合があり、Google のプライバシーポリシーおよび Firebase のプライバシーとセキュリティに関する説明に従って取り扱われます。クラッシュレポートは最大 90 日間保存され、統計データは Firebase の既定の保存ポリシーに従って保存されます。",
|
||||
"disclaimerLicenseTitle": "オープンソースライセンス",
|
||||
"disclaimerLicenseContent": "本ソフトウェアは GPL-3.0 ライセンスのもとでオープンソースとして公開されています。同ライセンスを遵守する限り、自由に使用、改変、再配布できますが、派生物も GPL-3.0 で公開し、原著作者の著作権表示を保持する必要があります。\n\n本ソフトウェアに含まれるサードパーティのコンポーネント(Clash.Meta コアを含む)は、それぞれのライセンスに従います。改変版や再配布版に起因する問題について、原著作者は責任を負いません。",
|
||||
"disclaimerChangesTitle": "本声明の変更",
|
||||
"disclaimerChangesContent": "開発者はバージョンの更新に伴い本声明を変更することがあり、変更内容は新しいバージョンの公開とともに効力を生じます。更新後も本ソフトウェアを引き続き使用した場合、変更後の声明に同意したものとみなされます。",
|
||||
"disclaimerAcceptTitle": "声明への同意",
|
||||
"disclaimerAcceptContent": "本ソフトウェアをインストール、複製または使用した時点で、本声明のすべての内容を読み、同意したものとみなされます。いずれかの条項に同意いただけない場合は、直ちに使用を中止し、本ソフトウェアをアンインストールしてください。",
|
||||
"disclaimerAndroidOnly": "Android のみ",
|
||||
"disclaimerGooglePrivacy": "Google プライバシーポリシー",
|
||||
"disclaimerFirebasePrivacy": "Firebase のプライバシーとセキュリティ",
|
||||
"agree": "同意する",
|
||||
"hotkeyManagement": "ホットキー管理",
|
||||
"hotkeyManagementDesc": "キーボードでアプリを操作します",
|
||||
"pressKeyboard": "キーを押してください",
|
||||
"inputCorrectHotkey": "正しいホットキーを入力してください",
|
||||
"hotkeyConflict": "ホットキーが競合しています",
|
||||
"hotkeyDesc": "グローバルホットキーはウィンドウが非表示でも有効です。アクションをタップしてキーの組み合わせを記録します。",
|
||||
"hotkeyNotSet": "未設定",
|
||||
"pressKeyboard": "キーの組み合わせを押してください",
|
||||
"hotkeyNeedsModifier": "{modifiers} のいずれかを含めてください",
|
||||
"hotkeyConflictWith": "「{action}」で使用中です。保存するとこちらに移動します。",
|
||||
"hotkeyUnavailable": "登録できませんでした。他のアプリが使用している可能性があります",
|
||||
"remove": "削除",
|
||||
"noHotKey": "ホットキーはまだありません",
|
||||
"noNetwork": "ネットワークがありません",
|
||||
"ipv6InboundDesc": "IPv6インバウンドを許可します",
|
||||
"exportLogs": "ログをエクスポート",
|
||||
"exportSuccess": "エクスポートが完了しました",
|
||||
"iconStyle": "アイコンスタイル",
|
||||
"onlyIcon": "アイコンのみ",
|
||||
"iconStyleFilled": "背景あり",
|
||||
"iconStylePlain": "背景なし",
|
||||
"iconStyleHidden": "非表示",
|
||||
"filter": "フィルター",
|
||||
"hideTimeoutProxies": "タイムアウトしたノードを隠す",
|
||||
"hideTimeoutProxiesDesc": "前回の遅延テストがタイムアウトしたノードを表示しない",
|
||||
"stackMode": "スタックモード",
|
||||
"network": "ネットワーク",
|
||||
"networkDesc": "ネットワーク関連の設定を変更します",
|
||||
"bypassDomain": "除外ドメイン",
|
||||
"bypassDomainDesc": "システムプロキシが有効な場合のみ適用されます",
|
||||
"resetTip": "リセットしてもよろしいですか?",
|
||||
@@ -284,7 +342,6 @@
|
||||
"routeModeBypassPrivate": "プライベートアドレスをバイパス",
|
||||
"routeModeConfig": "設定を使用",
|
||||
"routeAddress": "ルートアドレス",
|
||||
"routeAddressDesc": "リッスンするルートアドレスを設定します",
|
||||
"interfaceNameMode": "アウトバウンドインターフェース",
|
||||
"interfaceNameModeClear": "クリア",
|
||||
"interfaceNameModeFollow": "設定に従う",
|
||||
@@ -293,6 +350,20 @@
|
||||
"interfaceNameDesc": "アウトバウンド接続に使用するネットワークインターフェース名",
|
||||
"copyEnvVar": "環境変数をコピー",
|
||||
"memoryInfo": "メモリ情報",
|
||||
"total": "合計",
|
||||
"releaseMemory": "メモリを解放",
|
||||
"memoryCoreHeapInuse": "使用中のヒープ",
|
||||
"memoryCoreHeapIdle": "未使用のヒープ",
|
||||
"memoryCoreStack": "ゴルーチンスタック",
|
||||
"memoryCoreRuntime": "ランタイムのオーバーヘッド",
|
||||
"memoryCoreNotRunning": "コアは実行されていません",
|
||||
"memoryAppResident": "常駐メモリ",
|
||||
"memoryAppShared": "アプリと共有",
|
||||
"memoryEstimateDesc": "プロセスの常駐メモリからの推定値で、システムの表示とは異なる場合があります。",
|
||||
"memoryEstimateSharedDesc": "コアはアプリと同じプロセスで動作します。コア分はランタイム統計から推定し、残りはアプリと共有メモリとして計上します。",
|
||||
"memoryReleased": "メモリを解放しました",
|
||||
"memoryReleasedSize": "{size} を解放しました",
|
||||
"releaseMemoryFailed": "メモリの解放に失敗しました",
|
||||
"cancel": "キャンセル",
|
||||
"fileIsUpdate": "ファイルが変更されています。変更を保存しますか?",
|
||||
"profileHasUpdate": "プロファイルが変更されています。自動更新を無効にしますか?",
|
||||
@@ -305,27 +376,27 @@
|
||||
"listen": "リッスン",
|
||||
"undo": "元に戻す",
|
||||
"redo": "やり直す",
|
||||
"lineWrap": "折り返し",
|
||||
"none": "なし",
|
||||
"basicConfig": "基本設定",
|
||||
"basicConfigDesc": "基本設定をグローバルに変更します",
|
||||
"advancedConfig": "詳細設定",
|
||||
"advancedConfigDesc": "多彩な設定項目を提供します",
|
||||
"advancedConfigDesc": "ネットワーク、DNS、追加ルール、スクリプト",
|
||||
"selectedCountTitle": "{count} 件選択中",
|
||||
"addRule": "ルールを追加",
|
||||
"ruleName": "ルール名",
|
||||
"content": "内容",
|
||||
"subRule": "サブルール",
|
||||
"ruleTarget": "ルールターゲット",
|
||||
"matchTargetTitle": "マッチ先",
|
||||
"matchTarget": "MATCH-TARGET",
|
||||
"followProfile": "プロファイルに従う",
|
||||
"matchTargetDesc": "MATCH-TARGET を対象にしたルールの行き先。既定ではこのプロファイル末尾の MATCH ルールのターゲットを使います。",
|
||||
"selectMatchTarget": "MATCH-TARGET を選択",
|
||||
"sourceIp": "送信元IP",
|
||||
"noResolve": "IPを解決しない",
|
||||
"saveChanges": "変更を保存しますか?",
|
||||
"findProcessModeDesc": "有効にすると、パフォーマンスが多少低下します",
|
||||
"tabAnimationDesc": "モバイル表示でのみ有効です",
|
||||
"navigationBarStyle": "ボトムバー",
|
||||
"floating": "フローティング",
|
||||
"docked": "固定",
|
||||
"slide": "スライド",
|
||||
"fade": "フェード",
|
||||
"pureBlack": "ピュアブラック",
|
||||
"colorSchemes": "カラースキーム",
|
||||
"palette": "パレット",
|
||||
"tonalSpotScheme": "トーナルスポット",
|
||||
@@ -348,6 +419,8 @@
|
||||
"launchInterruptedTip": "前回、アプリは起動中に予期せず終了しました。今回の自動セットアップはスキップしました。手動で起動して再試行できます。",
|
||||
"clearData": "データを消去",
|
||||
"textScale": "テキストの拡大縮小",
|
||||
"textScalePreview": "アプリ内の文字はこの大きさで表示されます",
|
||||
"followSystem": "システムに従う",
|
||||
"internet": "インターネット",
|
||||
"systemApp": "システムアプリ",
|
||||
"noNetworkApp": "ネットワーク不使用アプリ",
|
||||
@@ -361,7 +434,13 @@
|
||||
"interval": "間隔",
|
||||
"existsTip": "{label}はすでに存在します",
|
||||
"maxLengthTip": "{label}は最大{max}文字です",
|
||||
"multipleValuesTip": "複数の値はカンマで区切ってください",
|
||||
"singleAdd": "個別追加",
|
||||
"batchAdd": "一括追加",
|
||||
"batchListInputTip": "1行に1項目、またはカンマ区切りで入力してください",
|
||||
"batchMapInputTip": "1行に1件、キーと値はスペースで区切ってください",
|
||||
"batchPreviewTip": "{count}件を追加、{skipped}件は既存のためスキップ",
|
||||
"lineIssueTip": "{line}行目:{message}",
|
||||
"singleValueTip": "{label}は1項目のみ指定できます",
|
||||
"deleteTip": "この{label}を削除してもよろしいですか?",
|
||||
"deleteMultipTip": "選択した{label}を削除してもよろしいですか?",
|
||||
"nullTip": "{label}はまだありません",
|
||||
@@ -378,6 +457,7 @@
|
||||
"import": "インポート",
|
||||
"importFile": "ファイルからインポート",
|
||||
"importUrl": "URLからインポート",
|
||||
"startFromScratch": "最初から作成",
|
||||
"autoSetSystemDns": "システムDNSを自動設定",
|
||||
"details": "{label}の詳細",
|
||||
"creationTime": "作成日時",
|
||||
@@ -396,7 +476,7 @@
|
||||
"request": "リクエスト",
|
||||
"connected": "接続済み",
|
||||
"disconnected": "切断済み",
|
||||
"connecting": "接続中...",
|
||||
"connecting": "接続中…",
|
||||
"restartCoreTip": "コアを再起動してもよろしいですか?",
|
||||
"forceRestartCoreTip": "コアを強制再起動してもよろしいですか?",
|
||||
"dnsHijacking": "DNSハイジャック",
|
||||
@@ -406,20 +486,25 @@
|
||||
"crashlytics": "クラッシュ分析",
|
||||
"crashlyticsTip": "有効にすると、アプリのクラッシュ時に機密情報を含まないクラッシュログを自動的にアップロードします",
|
||||
"appendSystemDns": "システムDNSを追加",
|
||||
"appendSystemDnsTip": "設定にシステムDNSを強制的に追加します",
|
||||
"editRule": "ルールを編集",
|
||||
"overrideMode": "上書きモード",
|
||||
"standardModeDesc": "標準モード:基本設定を上書きし、シンプルなルール追加機能を提供します",
|
||||
"scriptModeDesc": "スクリプトモード:外部の拡張スクリプトを使用し、ワンクリックで設定を上書きします",
|
||||
"addedRules": "追加ルール",
|
||||
"controlGlobalAddedRules": "グローバル追加ルールを管理",
|
||||
"quickAdd": "クイック追加",
|
||||
"rulePresetBlockQuic": "QUIC をブロック",
|
||||
"rulePresetBlockStun": "STUN をブロック",
|
||||
"rulePresetBlockDot": "DNS over TLS をブロック",
|
||||
"rulePresetSystemServicesDirect": "Apple と Microsoft に直接接続",
|
||||
"rulePresetBittorrentDirect": "BitTorrent を直接接続",
|
||||
"rulePresetLanDirect": "LAN 直接接続",
|
||||
"overrideScript": "上書きスクリプト",
|
||||
"goToConfigureScript": "スクリプト設定へ移動",
|
||||
"editGlobalRules": "グローバルルールを編集",
|
||||
"externalFetch": "外部取得",
|
||||
"confirmForceCrashCore": "コアを強制クラッシュさせてもよろしいですか?",
|
||||
"confirmClearAllData": "すべてのデータを消去してもよろしいですか?",
|
||||
"loading": "読み込み中...",
|
||||
"loading": "読み込み中…",
|
||||
"yearsAgo": "{count} 年前",
|
||||
"monthsAgo": "{count} か月前",
|
||||
"daysAgo": "{count} 日前",
|
||||
@@ -435,24 +520,31 @@
|
||||
"speedStatistics": "速度統計",
|
||||
"resetPageChangesTip": "このページには変更があります。リセットしてもよろしいですか?",
|
||||
"overwriteTypeCustom": "カスタム",
|
||||
"overwriteTypeCustomDesc": "カスタムモード:プロキシグループとルールを完全にカスタマイズできます",
|
||||
"overwriteTypeCustomDesc": "カスタムモード:プロキシ、プロキシグループ、ルールを完全にカスタマイズできます",
|
||||
"unknownNetworkError": "不明なネットワークエラー",
|
||||
"restoreException": "復元エラー",
|
||||
"networkException": "ネットワークエラーです。接続を確認してから再試行してください",
|
||||
"backupFromNewerVersion": "このバックアップは新しいバージョンのアプリで作成されています。アプリを更新してから復元してください",
|
||||
"networkTimeoutError": "リクエストがタイムアウトしました。ネットワークまたはプロキシを確認してから再試行してください",
|
||||
"networkHostLookupError": "サーバーのアドレスを解決できませんでした。URL が正しいこと、DNS が使えることを確認してください",
|
||||
"networkConnectionError": "サーバーに接続できませんでした。ネットワーク接続またはプロキシ設定を確認してください",
|
||||
"networkTlsError": "安全な接続に失敗しました。サーバー証明書が無効か、接続が傍受されている可能性があります",
|
||||
"networkCancelledError": "リクエストはキャンセルされました",
|
||||
"networkAccessDeniedError": "サーバーがアクセスを拒否しました(HTTP {code})。リンクの期限切れか、認証情報が誤っている可能性があります",
|
||||
"networkNotFoundError": "このアドレスには何も見つかりませんでした(HTTP {code})。URL が正しいか確認してください",
|
||||
"networkRateLimitedError": "リクエストが多すぎます(HTTP 429)。しばらく待ってから再試行してください",
|
||||
"networkServerError": "サーバーで問題が発生しました(HTTP {code})。しばらくしてから再試行してください",
|
||||
"networkBadResponseError": "サーバーがリクエストを拒否しました(HTTP {code})",
|
||||
"networkRequestFailed": "ネットワークリクエストに失敗しました:{detail}",
|
||||
"invalidBackupFile": "無効なバックアップファイル",
|
||||
"pruneCache": "キャッシュを整理",
|
||||
"backupAndRestore": "バックアップと復元",
|
||||
"backupAndRestoreDesc": "WebDAVまたはファイルでデータを同期します",
|
||||
"restore": "復元",
|
||||
"restoreSuccess": "復元が完了しました",
|
||||
"restoreFromWebDAVDesc": "WebDAVからデータを復元します",
|
||||
"restoreFromFileDesc": "ファイルからデータを復元します",
|
||||
"restoreOnlyConfig": "プロファイルのみ復元",
|
||||
"restoreAllData": "すべてのデータを復元",
|
||||
"addProfile": "プロファイルを追加",
|
||||
"delayTest": "遅延テスト",
|
||||
"proxyGroupEmpty": "プロキシグループが空です",
|
||||
"proxyGroupNameEmpty": "プロキシグループ名は空にできません",
|
||||
"proxyGroupNameDuplicate": "プロキシグループ名が重複しています",
|
||||
"confirmExitWindow": "現在のウィンドウを閉じてもよろしいですか?",
|
||||
"dataChangedSave": "データの変更を検出しました。保存しますか?",
|
||||
@@ -464,6 +556,8 @@
|
||||
"excludeProxyFilter": "除外ノードフィルター",
|
||||
"excludeType": "除外タイプ",
|
||||
"expectedStatus": "期待するステータス",
|
||||
"strategy": "戦略",
|
||||
"tolerance": "許容値",
|
||||
"selectProxies": "プロキシを選択",
|
||||
"inputProxyGroupName": "プロキシグループ名を入力してください",
|
||||
"helperCorruptTip": "Helper サービスが利用できないため、TUN モードを有効にできません。FlClash を再インストールしてください。",
|
||||
@@ -488,7 +582,7 @@
|
||||
"basicStrategy": "基本ポリシー",
|
||||
"editProxy": "プロキシを編集",
|
||||
"includeAllProxyProviders": "すべてのプロキシプロバイダーを含める",
|
||||
"includeAllProxyProvidersTip": "有効にすると、取り込んだプロキシプロバイダーを上書きします",
|
||||
"includeAllProxyProvidersTip": "有効にすると、このプロファイルのすべてのプロキシプロバイダーを含めます。サブスクリプション自身のものに加え、いずれかのプロキシグループが使うプロファイルとアプリのプロキシプロバイダーも含みます",
|
||||
"addProxyProviders": "プロキシプロバイダーを追加",
|
||||
"includeAllProxies": "すべてのプロキシを含める",
|
||||
"includeAllProxiesTip": "プロキシグループに属さないすべてのプロキシを取り込みます。下でプロキシグループを追加できます",
|
||||
@@ -550,11 +644,11 @@
|
||||
"invalidSubRule": "{subRule} は無効な SUB_RULE です",
|
||||
"invalidPolicy": "{target} は無効なポリシーです",
|
||||
"invalidProxyProvider": "{providerName} は無効なプロキシプロバイダーです",
|
||||
"invalidRuleSet": "{ruleSet} は無効なルールセットです",
|
||||
"invalidNetworkContent": "tcp または udp のみ対応しています",
|
||||
"invalidRangeContent": "80 や 8000-9000 のような数値または範囲を / 区切りで入力してください",
|
||||
"invalidDscpContent": "DSCP マークは 63 を超えられません",
|
||||
"invalidProxy": "{proxyName} は無効なプロキシです",
|
||||
"proxyGroupDetectedAbnormal": "現在のプロキシグループに異常が見つかりました",
|
||||
"proxyProviderDetectedAbnormal": "選択したプロキシプロバイダーに異常が見つかりました",
|
||||
"proxyDetectedAbnormal": "選択したプロキシに異常が見つかりました",
|
||||
"createProfile": "プロファイルを作成",
|
||||
"locationPermissionRequired": "位置情報の権限が必要です",
|
||||
"locationPermissionGuide": "1. システム設定 > プライバシーとセキュリティ を開く\n2. 位置情報サービス を選択\n3. リストで {appName} を見つけてチェックを入れる\n\n設定が完了したらアプリに戻ると、通常どおり使用できます。ご協力ありがとうございます。",
|
||||
"prerequisites": "前提条件",
|
||||
@@ -573,7 +667,7 @@
|
||||
"editSsid": "SSIDを編集",
|
||||
"authorized": "許可済み",
|
||||
"tapToAuthorize": "タップして許可",
|
||||
"suspended": "一時停止中...",
|
||||
"suspended": "一時停止中…",
|
||||
"geoOptions": "Geoオプション",
|
||||
"geoAutoUpdate": "自動更新",
|
||||
"geoAutoUpdateInterval": "自動更新間隔",
|
||||
@@ -584,7 +678,6 @@
|
||||
"geoSkipped": "{name} はすでに最新です",
|
||||
"geoUpdated": "{name} を更新しました",
|
||||
"secondsCount": "{count} 秒",
|
||||
"entriesCount": "{count} 件",
|
||||
"proxiesCount": "プロキシ {count} 件",
|
||||
"rulesCount": "ルール {count} 件",
|
||||
"changelogBreaking": "破壊的変更",
|
||||
@@ -600,7 +693,6 @@
|
||||
"pinWindow": "最前面に固定",
|
||||
"unpinWindow": "固定を解除",
|
||||
"exitFullScreen": "全画面表示を終了",
|
||||
"toggleLabel": "ラベルを切り替え",
|
||||
"torch": "ライト",
|
||||
"pickFromAlbum": "アルバムから選択",
|
||||
"blockConnection": "接続をブロック",
|
||||
@@ -611,11 +703,79 @@
|
||||
"previousMatch": "前の一致",
|
||||
"nextMatch": "次の一致",
|
||||
"clearSearch": "検索をクリア",
|
||||
"noSearchResults": "一致する結果はありません",
|
||||
"addWidget": "ウィジェットを追加",
|
||||
"showPassword": "パスワードを表示",
|
||||
"hidePassword": "パスワードを隠す",
|
||||
"authorize": "許可",
|
||||
"installedAppsPermissionRequired": "アプリ一覧の権限が必要です",
|
||||
"installedAppsPermissionDesc": "このシステムでは、許可するまでインストール済みアプリの一覧が提供されません。許可すると、アプリごとのプロキシを設定できます。",
|
||||
"installedAppsPermissionDeniedMessage": "アプリ一覧の権限が拒否されたため、インストール済みアプリを取得できません。システム設定から手動で許可してください。"
|
||||
"installedAppsPermissionDeniedMessage": "アプリ一覧の権限が拒否されたため、インストール済みアプリを取得できません。システム設定から手動で許可してください。",
|
||||
"outboundIp": "アウトバウンド IP",
|
||||
"ipAddress": "IP アドレス",
|
||||
"ipQualityLevel": "レベル",
|
||||
"ipQualityGood": "良好",
|
||||
"ipQualityNormal": "普通",
|
||||
"ipQualityRisky": "リスクあり",
|
||||
"ipType": "タイプ",
|
||||
"ipTypeResidential": "住宅",
|
||||
"ipTypeMobile": "モバイル回線",
|
||||
"ipTypeBusiness": "ビジネス",
|
||||
"ipTypeHosting": "データセンター",
|
||||
"ipFlags": "検出",
|
||||
"ipFlagProxy": "プロキシ",
|
||||
"ipFlagVpn": "VPN",
|
||||
"ipFlagTor": "Tor",
|
||||
"ipFlagAbuser": "不正利用の履歴",
|
||||
"ipOrganization": "組織",
|
||||
"ipAsn": "ASN",
|
||||
"ipQualitySource": "採用したソース",
|
||||
"ipQualitySources": "各ソース",
|
||||
"ipQualityFailed": "IP タイプを判定できませんでした",
|
||||
"ipQualityRetry": "再確認",
|
||||
"ipSourceNoType": "判定不可",
|
||||
"ipSourceRateLimited": "レート制限",
|
||||
"ipSourceIpMismatch": "アウトバウンド IP が不一致",
|
||||
"hideIp": "IP を隠す",
|
||||
"editorUnavailable": "エディターを利用できません",
|
||||
"replace": "置換",
|
||||
"replaceAll": "すべて置換",
|
||||
"cameraPermissionRequired": "カメラの権限が必要です",
|
||||
"cameraPermissionDesc": "QRコードをスキャンするには、システム設定でカメラへのアクセスを許可するか、アルバムからQRコード画像を選択してください。",
|
||||
"cameraUnavailable": "カメラを使用できません",
|
||||
"retry": "再試行",
|
||||
"invalidProfileQrcode": "このQRコードにはプロファイルのリンクが含まれていません",
|
||||
"dnsQueries": "DNSクエリ",
|
||||
"recordType": "レコードタイプ",
|
||||
"answers": "応答",
|
||||
"responseCode": "応答コード",
|
||||
"error": "エラー",
|
||||
"initiator": "発信元",
|
||||
"cache": "キャッシュ",
|
||||
"proxyNode": "プロキシノード",
|
||||
"yes": "はい",
|
||||
"no": "いいえ",
|
||||
"nonTextProviderFile": "この外部リソースはテキストファイルではありません",
|
||||
"addCustomProxy": "プロキシを追加",
|
||||
"proxyDefinition": "完全な設定",
|
||||
"proxyDefinitionNotMap": "設定は name と type を含む YAML マッピングである必要があります",
|
||||
"customProxiesEmpty": "カスタムプロキシがないため、プロファイル自身のプロキシを使用します",
|
||||
"overwriteIssueEmptyName": "名前が空です",
|
||||
"overwriteIssueReservedName": "{name} は組み込みポリシー名のため使用できません",
|
||||
"overwriteIssueDuplicateName": "名前 {name} は他のプロキシまたはプロキシグループで使用されています",
|
||||
"overwriteIssueCoreRejected": "コアがこのプロキシを解析できません:{message}",
|
||||
"overwriteIssueMissingProxies": "次のプロキシまたはポリシーは存在しません:{names}",
|
||||
"overwriteIssueMissingProviders": "次のプロキシプロバイダーは存在しません:{names}",
|
||||
"overwriteIssueNoProxySource": "プロキシもプロキシプロバイダーも選択されていないため、コアはこのグループを拒否します",
|
||||
"overwriteIssueGroupLoop": "プロキシグループが循環参照しています:{path}",
|
||||
"overwriteIssuesSummary": "{count} 件に問題があり、上書きの適用に失敗する可能性があります",
|
||||
"overwriteIssueSubscriptionGroupMissingProxies": "プロファイル自身のプロキシグループが、カスタムプロキシに含まれないプロキシを参照しています:{names}",
|
||||
"providerInUse": "{label} は {profiles} のカスタムプロキシグループまたはルールでまだ使用されています。先にそこから外してください",
|
||||
"providerSourceSubscription": "サブスクリプション",
|
||||
"providerRenameShadowed": "{profiles} のサブスクリプションには既に {label} があるため、名前を変えるとそちらが使われます。別の名前にしてください",
|
||||
"fontSize": "サイズ",
|
||||
"large": "大",
|
||||
"extraLarge": "特大",
|
||||
"switchProfile": "プロファイルを切り替え",
|
||||
"lastUpdated": "最終更新"
|
||||
}
|
||||
|
||||
@@ -9,19 +9,32 @@
|
||||
"profiles": "Профили",
|
||||
"tools": "Инструменты",
|
||||
"logs": "Логи",
|
||||
"logsDesc": "Записи захваченных логов",
|
||||
"resources": "Ресурсы",
|
||||
"resourcesDesc": "Сведения о внешних ресурсах",
|
||||
"trafficUsage": "Статистика трафика",
|
||||
"networkSpeed": "Скорость сети",
|
||||
"outboundMode": "Режим исходящего трафика",
|
||||
"networkDetection": "Проверка сети",
|
||||
"serviceStatus": "Состояние сервисов",
|
||||
"serviceRestricted": "Доступ ограничен",
|
||||
"serviceAvailable": "Доступен",
|
||||
"serviceUnavailable": "Недоступен",
|
||||
"serviceFailed": "Ошибка проверки",
|
||||
"serviceDisallowedIsp": "Недопустимый провайдер",
|
||||
"serviceBlocked": "Заблокировано",
|
||||
"serviceUnsupportedRegion": "Регион не поддерживается",
|
||||
"serviceOriginalsOnly": "Только оригиналы",
|
||||
"serviceComingSoon": "Скоро появится",
|
||||
"servicePending": "Не проверено",
|
||||
"serviceManage": "Управление сервисами",
|
||||
"serviceCheck": "Проверить",
|
||||
"serviceCheckAll": "Проверить все",
|
||||
"serviceCheckedAt": "Проверено в {time}",
|
||||
"upload": "Отдача",
|
||||
"download": "Загрузка",
|
||||
"usedTraffic": "Использованный трафик",
|
||||
"totalTraffic": "Общий трафик",
|
||||
"expireTime": "Срок действия",
|
||||
"nullProfileDesc": "Профилей пока нет. Сначала добавьте профиль",
|
||||
"nullProfileDesc": "Добавьте профиль, чтобы начать",
|
||||
"settings": "Настройки",
|
||||
"language": "Язык",
|
||||
"defaultText": "По умолчанию",
|
||||
@@ -36,27 +49,22 @@
|
||||
"themeDesc": "Тёмный режим и настройка цветов",
|
||||
"override": "Переопределение",
|
||||
"allowLan": "Разрешить LAN",
|
||||
"allowLanDesc": "Разрешить доступ к прокси из локальной сети",
|
||||
"tun": "TUN",
|
||||
"tunDesc": "Работает только в режиме администратора",
|
||||
"minimizeOnExit": "Сворачивать при выходе",
|
||||
"minimizeOnExitDesc": "Изменяет стандартное поведение при выходе",
|
||||
"autoLaunch": "Автозапуск",
|
||||
"autoLaunchDesc": "Запускаться автоматически при старте системы",
|
||||
"silentLaunch": "Тихий запуск",
|
||||
"silentLaunchDesc": "Запускаться в фоновом режиме",
|
||||
"silentLaunchDesc": "Запускаться без показа окна",
|
||||
"autoRun": "Автовключение",
|
||||
"autoRunDesc": "Включаться автоматически при открытии приложения",
|
||||
"logcat": "Захват логов",
|
||||
"logcatDesc": "При отключении раздел логов будет скрыт",
|
||||
"autoCheckUpdate": "Автопроверка обновлений",
|
||||
"autoCheckUpdateDesc": "Автоматически проверять обновления при запуске приложения",
|
||||
"checkCertificate": "Проверять TLS-сертификаты",
|
||||
"checkCertificateDesc": "Отклонять недоверенные сертификаты. Отключение подвергает подписки и резервные копии атаке «человек посередине»",
|
||||
"accessControl": "Контроль доступа",
|
||||
"accessControlDesc": "Выбор приложений, использующих прокси",
|
||||
"application": "Приложение",
|
||||
"applicationDesc": "Настройки, связанные с приложением",
|
||||
"edit": "Редактировать",
|
||||
"confirm": "Подтвердить",
|
||||
"update": "Обновить",
|
||||
@@ -103,12 +111,15 @@
|
||||
"show": "Показать",
|
||||
"exit": "Выход",
|
||||
"systemProxy": "Системный прокси",
|
||||
"safeMode": "Безопасный режим",
|
||||
"safeModeAppTitle": "{appName} (Безопасный режим)",
|
||||
"project": "Проект",
|
||||
"core": "Ядро",
|
||||
"tabAnimation": "Анимация вкладок",
|
||||
"desc": "Многоплатформенный прокси-клиент на основе ClashMeta: простой и удобный, с открытым исходным кодом и без рекламы.",
|
||||
"startVpn": "Запуск VPN...",
|
||||
"stopVpn": "Остановка VPN...",
|
||||
"startVpn": "Запуск VPN…",
|
||||
"stopVpn": "Остановка VPN…",
|
||||
"localNetworkDeniedTip": "Доступ к локальной сети запрещён: используется стек gvisor, локальная сеть недоступна.",
|
||||
"compatible": "Режим совместимости",
|
||||
"notSelectedTip": "Текущую группу прокси нельзя выбрать",
|
||||
"changeProxyFailedTip": "Не удалось переключить прокси; восстановлен предыдущий выбор",
|
||||
@@ -137,32 +148,31 @@
|
||||
"country": "Регион",
|
||||
"search": "Поиск",
|
||||
"allowBypass": "Разрешить приложениям обходить VPN",
|
||||
"allowBypassDesc": "При включении некоторые приложения смогут обходить VPN",
|
||||
"externalController": "Внешний контроллер",
|
||||
"externalControllerDesc": "При включении ядром Clash можно управлять через порт 9090",
|
||||
"ipv6Desc": "При включении можно принимать трафик IPv6",
|
||||
"app": "Приложение",
|
||||
"general": "Общие",
|
||||
"systemProxyDesc": "Настроить системный прокси",
|
||||
"startupAndBackground": "Запуск и фоновая работа",
|
||||
"requestsAndUpdates": "Запросы и обновления",
|
||||
"logsAndDiagnostics": "Логи и диагностика",
|
||||
"userAgent": "User-Agent",
|
||||
"unifiedDelay": "Единая задержка",
|
||||
"unifiedDelayDesc": "Убирает лишние задержки, например рукопожатие",
|
||||
"tcpConcurrent": "Параллельный TCP",
|
||||
"tcpConcurrentDesc": "При включении разрешает параллельные TCP-подключения",
|
||||
"geodataLoader": "Geo: экономия памяти",
|
||||
"geodataLoaderDesc": "При включении используется Geo-загрузчик с низким потреблением памяти",
|
||||
"requests": "Запросы",
|
||||
"requestsDesc": "Просмотр последних запросов",
|
||||
"recentRequests": "Последние запросы",
|
||||
"findProcessMode": "Поиск процесса",
|
||||
"init": "Инициализация",
|
||||
"infiniteTime": "Бессрочно",
|
||||
"connections": "Соединения",
|
||||
"liveConnections": "Активные соединения",
|
||||
"inbound": "Входящие",
|
||||
"connectionsDesc": "Просмотр данных о текущих соединениях",
|
||||
"intranetIP": "Внутренний IP",
|
||||
"view": "Просмотр",
|
||||
"cut": "Вырезать",
|
||||
"copy": "Копировать",
|
||||
"clipboardWriteFailed": "Не удалось скопировать в буфер обмена. Возможно, выделение слишком велико",
|
||||
"paste": "Вставить",
|
||||
"testUrl": "URL для теста",
|
||||
"sync": "Синхронизация",
|
||||
@@ -182,19 +192,18 @@
|
||||
"go": "Перейти",
|
||||
"externalLink": "Внешняя ссылка",
|
||||
"otherContributors": "Другие участники",
|
||||
"appIconDesign": "Дизайн значка приложения",
|
||||
"autoCloseConnections": "Автозакрытие соединений",
|
||||
"autoCloseConnectionsDesc": "Автоматически закрывать соединения после смены узла",
|
||||
"onlyStatisticsProxy": "Учитывать только прокси",
|
||||
"onlyStatisticsProxyDesc": "При включении учитывается только трафик через прокси",
|
||||
"onlyStatisticsProxy": "Учитывать только трафик прокси",
|
||||
"showNotificationStopAction": "Кнопка остановки в уведомлении",
|
||||
"showNotificationStopActionDesc": "Показывать кнопку остановки в постоянном уведомлении. Отключите, если из-за неё система всегда разворачивает уведомление",
|
||||
"pureBlackMode": "Чисто чёрный режим",
|
||||
"sidebarBlur": "Размытие боковой панели",
|
||||
"sidebarBlurDesc": "Показывать сквозь боковую панель размытый рабочий стол за окном",
|
||||
"keepAliveIntervalDesc": "Интервал TCP keep-alive",
|
||||
"entries": " записей",
|
||||
"local": "Локально",
|
||||
"remote": "Удалённо",
|
||||
"remoteBackupDesc": "Резервное копирование данных в WebDAV",
|
||||
"localBackupDesc": "Резервное копирование данных локально",
|
||||
"mode": "Режим",
|
||||
"time": "Время",
|
||||
"source": "Источник",
|
||||
@@ -208,70 +217,119 @@
|
||||
"loose": "Свободный",
|
||||
"profilesSort": "Сортировка профилей",
|
||||
"start": "Старт",
|
||||
"runTime": "Время работы",
|
||||
"stop": "Стоп",
|
||||
"dnsDesc": "Настройки, связанные с DNS",
|
||||
"key": "Ключ",
|
||||
"value": "Значение",
|
||||
"hostsDesc": "Добавить записи hosts",
|
||||
"vpnTip": "Изменения вступят в силу после перезапуска VPN",
|
||||
"vpnEnableDesc": "Автоматически направляет весь системный трафик через VpnService",
|
||||
"options": "Опции",
|
||||
"loopback": "Инструмент разблокировки loopback",
|
||||
"loopbackDesc": "Для снятия ограничения loopback у UWP-приложений",
|
||||
"enabled": "Включено",
|
||||
"disabled": "Выключено",
|
||||
"loopback": "Снятие ограничения loopback для UWP",
|
||||
"providers": "Внешние ресурсы",
|
||||
"proxyProviders": "Провайдеры прокси",
|
||||
"ruleProviders": "Провайдеры правил",
|
||||
"behavior": "Поведение",
|
||||
"format": "Формат",
|
||||
"providerUrlTip": "Поддерживаются только удалённые ресурсы",
|
||||
"subscriptionInfo": "Информация о подписке",
|
||||
"overrideDns": "Переопределить DNS",
|
||||
"overrideDnsDesc": "При включении настройки DNS профиля переопределяются",
|
||||
"overrideNtp": "Переопределить NTP",
|
||||
"ntpStatusDesc": "Брать время с NTP-сервера, а не из системных часов",
|
||||
"server": "Сервер",
|
||||
"ntpInterval": "Интервал синхронизации (минуты)",
|
||||
"dialerProxy": "Прокси для подключения",
|
||||
"dialerProxyDesc": "Исход, через который идёт обращение к NTP-серверу",
|
||||
"writeToSystem": "Записывать в систему",
|
||||
"writeToSystemDesc": "Также устанавливать системные часы; Android это игнорирует",
|
||||
"overrideEntries": "Переопределяемые параметры",
|
||||
"addOverrideEntry": "Добавить параметр",
|
||||
"discardChanges": "Отменить изменения?",
|
||||
"quickEdit": "Быстрое редактирование",
|
||||
"status": "Статус",
|
||||
"statusDesc": "При отключении используется системный DNS",
|
||||
"preferH3Desc": "Предпочитать HTTP/3 для DoH",
|
||||
"respectRules": "Соблюдать правила",
|
||||
"respectRulesDesc": "DNS-соединения следуют правилам; требуется настроить proxy-server-nameserver",
|
||||
"respectRulesDesc": "DNS-соединения следуют правилам; требуется Proxy Server Nameserver",
|
||||
"dnsMode": "Режим DNS",
|
||||
"fakeipRange": "Диапазон Fake-IP",
|
||||
"fakeipFilter": "Фильтр Fake-IP",
|
||||
"defaultNameserver": "DNS-сервер по умолчанию",
|
||||
"defaultNameserverDesc": "Используется для разрешения адресов DNS-серверов",
|
||||
"nameserver": "DNS-сервер",
|
||||
"nameserverDesc": "Используется для разрешения доменов",
|
||||
"useHosts": "Использовать hosts",
|
||||
"useSystemHosts": "Использовать системный hosts",
|
||||
"nameserverPolicy": "Политика DNS-серверов",
|
||||
"nameserverPolicyDesc": "Задать политику DNS-серверов для доменов",
|
||||
"proxyNameserver": "DNS-сервер для прокси",
|
||||
"proxyNameserverDesc": "Используется для разрешения доменов прокси-узлов",
|
||||
"fallback": "Fallback",
|
||||
"fallbackDesc": "Обычно зарубежный DNS",
|
||||
"fallbackFilter": "Фильтр fallback",
|
||||
"geoipCode": "Код GeoIP",
|
||||
"ipcidr": "IP/CIDR",
|
||||
"domain": "Домен",
|
||||
"listenRoutingMark": "Метка маршрутизации",
|
||||
"listenRoutingMarkDesc": "Только Linux",
|
||||
"ipv6Timeout": "Тайм-аут IPv6 (мс)",
|
||||
"cacheAlgorithm": "Алгоритм кэша",
|
||||
"cacheMaxSize": "Размер кэша",
|
||||
"fakeipRange6": "Диапазон Fake-IP (IPv6)",
|
||||
"fakeipFilterMode": "Режим фильтра Fake-IP",
|
||||
"fakeipFilterModeDesc": "blacklist исключает совпадения, whitelist — только их, rule — по правилам",
|
||||
"fakeipTtl": "TTL Fake-IP",
|
||||
"reset": "Сброс",
|
||||
"actionView": "Показать/Скрыть",
|
||||
"actionStart": "Старт/Стоп",
|
||||
"actionMode": "Переключить режим",
|
||||
"actionProxy": "Системный прокси",
|
||||
"actionTun": "TUN",
|
||||
"actionRuleMode": "Режим правил",
|
||||
"actionGlobalMode": "Глобальный режим",
|
||||
"actionDirectMode": "Прямой режим",
|
||||
"actionDelayTest": "Проверить все задержки",
|
||||
"actionUpdateProfiles": "Обновить профили",
|
||||
"disclaimer": "Отказ от ответственности",
|
||||
"disclaimerDesc": "Это программное обеспечение предназначено только для некоммерческого использования: обучения, обмена опытом и научных исследований. Коммерческое использование строго запрещено; любая коммерческая деятельность не имеет отношения к этому программному обеспечению.",
|
||||
"disclaimerDesc": "Перед использованием FlClash (далее — «Программа») внимательно прочитайте это заявление и убедитесь, что понимаете его полностью. Нажимая «Согласен», вы подтверждаете, что прочитали, поняли и принимаете все приведённые ниже условия. Если вы не согласны, нажмите «Выход» и прекратите использование Программы.",
|
||||
"disclaimerSoftwareTitle": "Характер программы",
|
||||
"disclaimerSoftwareContent": "Программа — это клиент сетевого прокси с открытым исходным кодом на основе ядра Clash.Meta (mihomo). Она предоставляет только локальные инструменты: управление конфигурациями, маршрутизацию по правилам и пересылку трафика.\n\nСама Программа не предоставляет прокси-серверы, узлы, подписки или услуги доступа к сети и не состоит в партнёрских, агентских или гарантийных отношениях с поставщиками таких услуг.",
|
||||
"disclaimerUsageTitle": "Ограничения использования",
|
||||
"disclaimerUsageContent": "Программа предназначена только для некоммерческого использования: обучения, обмена опытом и технических исследований. Любое коммерческое использование строго запрещено, включая, помимо прочего, платное распространение, продажу в комплекте, использование в составе коммерческого сервиса или ведение деятельности от имени Программы. Любая коммерческая деятельность не имеет отношения к Программе и её разработчикам.\n\nСтрого запрещено использовать Программу для действий, нарушающих законы вашей страны или региона, включая, помимо прочего, обход законно установленных ограничений доступа к сети, распространение незаконной информации, сетевые атаки и нарушение законных прав других лиц.",
|
||||
"disclaimerResponsibilityTitle": "Ваша ответственность",
|
||||
"disclaimerResponsibilityContent": "Вы самостоятельно убеждаетесь, что использование Программы законно в вашей стране или регионе, и единолично несёте юридическую ответственность за все действия с ней и их последствия.\n\nПодписки, узлы и конфигурации, которые вы импортируете, выбираете вы сами. Законность их источника, безопасность содержимого и надёжность сервиса — вопрос между вами и их поставщиками.",
|
||||
"disclaimerThirdPartyTitle": "Сторонний контент",
|
||||
"disclaimerThirdPartyContent": "Ссылки на подписки, файлы конфигурации, наборы правил, скрипты, внешние ресурсы и внешние ссылки предоставляются третьими лицами. Разработчики не могут проверять и не проверяют их законность, точность, безопасность и доступность и не дают на них никаких гарантий.\n\nУтечка данных, финансовые потери, блокировка аккаунта или иной ущерб, вызванные сторонним контентом, урегулируются между вами и третьим лицом; разработчики не несут за это никакой ответственности.",
|
||||
"disclaimerWarrantyTitle": "Отсутствие гарантий",
|
||||
"disclaimerWarrantyContent": "Программа предоставляется «как есть» и «по мере доступности», без каких-либо явных или подразумеваемых гарантий, включая, помимо прочего, гарантии товарной пригодности, пригодности для определённой цели, ненарушения прав, бесперебойной работы, отсутствия ошибок и уязвимостей.\n\nРазработчики не гарантируют, что Программа будет соответствовать вашим потребностям или работать без сбоев и ошибок.",
|
||||
"disclaimerLiabilityTitle": "Ограничение ответственности",
|
||||
"disclaimerLiabilityContent": "В максимальной степени, допустимой применимым законодательством, ни разработчики, ни участники проекта не несут ответственности за любой прямой, косвенный, случайный, особый, штрафной или последующий ущерб, возникший в результате использования или невозможности использования Программы, включая, помимо прочего, потерю данных, повреждение устройства, сбои сети, прерывание деятельности, упущенную выгоду или связанные с этим правовые споры, даже если они были предупреждены о возможности такого ущерба.",
|
||||
"disclaimerPrivacyTitle": "Сбор данных и конфиденциальность",
|
||||
"disclaimerPrivacyContent": "Программа не собирает и не отправляет адреса ваших подписок, сведения об узлах, содержимое конфигураций, посещённые сайты, записи о подключениях, содержимое трафика и журналы. Эти данные хранятся только на вашем устройстве, и у разработчиков нет к ним доступа.\n\nПрограмма обращается к сети только при использовании соответствующих функций, например загружает указанный вами адрес подписки при обновлении профиля или обращается к GitHub при проверке обновлений.\n\nНастольные версии (Windows, macOS, Linux) не содержат никаких сервисов статистики или отчётов о сбоях. Версия для Android использует два сервиса Google Firebase для повышения стабильности:",
|
||||
"disclaimerCrashlyticsTitle": "Firebase Crashlytics (аналитика сбоев)",
|
||||
"disclaimerCrashlyticsContent": "При сбое приложения отчёт о сбое отправляется автоматически.\n\nЧто собирается: трассировка стека и сообщение об ошибке, время сбоя, версия и номер сборки приложения, производитель и модель устройства, версия Android, ориентация экрана, свободная память и место в хранилище, наличие root-доступа, а также случайный идентификатор установки, который создаётся при установке и сбрасывается при переустановке.\n\nЦель: только поиск и исправление сбоев.\n\nВы можете отключить это в любой момент: «Инструменты > Общие > Аналитика сбоев».",
|
||||
"disclaimerAnalyticsTitle": "Firebase Analytics (статистика использования)",
|
||||
"disclaimerAnalyticsContent": "Вместе с Firebase автоматически собирается базовая статистика использования приложения.\n\nЧто собирается: базовые события, такие как первый запуск, открытие приложения и длительность сеанса, обновление приложения; идентификатор экземпляра приложения; модель устройства, версия ОС и язык системы; приблизительное местоположение на уровне страны или региона, определённое по IP-адресу.\n\nЦель: только оценка числа активных устройств, распределения версий и совместимости с ОС. Разработчики не используют эти данные для рекламы, не продают их и не связывают с вашими подписками или конфигурациями.",
|
||||
"disclaimerDataProcessingContent": "Эти данные обрабатываются и хранятся компанией Google от нашего имени, могут передаваться на серверы за пределами вашей страны или региона (например, в США) и регулируются Политикой конфиденциальности Google и документацией Firebase о конфиденциальности и безопасности. Отчёты о сбоях хранятся до 90 дней; статистика хранится в соответствии с политикой хранения Firebase по умолчанию.",
|
||||
"disclaimerLicenseTitle": "Лицензия с открытым исходным кодом",
|
||||
"disclaimerLicenseContent": "Программа распространяется с открытым исходным кодом по лицензии GPL-3.0. Вы можете свободно использовать, изменять и распространять её при соблюдении этой лицензии: производные работы также должны распространяться по GPL-3.0 с сохранением уведомлений об авторских правах.\n\nСторонние компоненты Программы, включая ядро Clash.Meta, распространяются по своим собственным лицензиям. Авторы оригинала не несут ответственности за проблемы, возникшие в изменённых или распространяемых третьими лицами версиях.",
|
||||
"disclaimerChangesTitle": "Изменения заявления",
|
||||
"disclaimerChangesContent": "Разработчики могут изменять это заявление в любом выпуске; изменения вступают в силу с момента публикации выпуска. Продолжая пользоваться Программой после обновления, вы принимаете изменённое заявление.",
|
||||
"disclaimerAcceptTitle": "Принятие условий",
|
||||
"disclaimerAcceptContent": "Устанавливая, копируя или используя Программу, вы подтверждаете, что прочитали и приняли это заявление полностью. Если вы не согласны с каким-либо его условием, немедленно прекратите использование и удалите Программу.",
|
||||
"disclaimerAndroidOnly": "Только Android",
|
||||
"disclaimerGooglePrivacy": "Политика конфиденциальности Google",
|
||||
"disclaimerFirebasePrivacy": "Конфиденциальность и безопасность Firebase",
|
||||
"agree": "Согласен",
|
||||
"hotkeyManagement": "Горячие клавиши",
|
||||
"hotkeyManagementDesc": "Управление приложением с клавиатуры",
|
||||
"pressKeyboard": "Нажмите клавишу",
|
||||
"inputCorrectHotkey": "Введите корректную горячую клавишу",
|
||||
"hotkeyConflict": "Конфликт горячих клавиш",
|
||||
"hotkeyDesc": "Глобальные горячие клавиши работают, даже когда окно скрыто. Нажмите на действие, чтобы записать сочетание клавиш.",
|
||||
"hotkeyNotSet": "Не задано",
|
||||
"pressKeyboard": "Нажмите сочетание клавиш",
|
||||
"hotkeyNeedsModifier": "Добавьте хотя бы одну из клавиш: {modifiers}",
|
||||
"hotkeyConflictWith": "Уже используется для «{action}». При сохранении будет перенесено сюда.",
|
||||
"hotkeyUnavailable": "Не зарегистрировано: сочетание может быть занято другим приложением",
|
||||
"remove": "Убрать",
|
||||
"noHotKey": "Горячих клавиш пока нет",
|
||||
"noNetwork": "Нет сети",
|
||||
"ipv6InboundDesc": "Разрешить входящий IPv6",
|
||||
"exportLogs": "Экспорт логов",
|
||||
"exportSuccess": "Экспорт выполнен",
|
||||
"iconStyle": "Стиль значков",
|
||||
"onlyIcon": "Только значок",
|
||||
"iconStyleFilled": "С подложкой",
|
||||
"iconStylePlain": "Без подложки",
|
||||
"iconStyleHidden": "Скрыто",
|
||||
"filter": "Фильтр",
|
||||
"hideTimeoutProxies": "Скрывать узлы с таймаутом",
|
||||
"hideTimeoutProxiesDesc": "Не показывать узлы, у которых последний тест задержки завершился таймаутом",
|
||||
"stackMode": "Режим стека",
|
||||
"network": "Сеть",
|
||||
"networkDesc": "Настройки, связанные с сетью",
|
||||
"bypassDomain": "Исключённые домены",
|
||||
"bypassDomainDesc": "Действует только при включённом системном прокси",
|
||||
"resetTip": "Вы уверены, что хотите выполнить сброс?",
|
||||
@@ -284,7 +342,6 @@
|
||||
"routeModeBypassPrivate": "Обходить частные адреса",
|
||||
"routeModeConfig": "Использовать конфигурацию",
|
||||
"routeAddress": "Адреса маршрутов",
|
||||
"routeAddressDesc": "Настроить прослушиваемые адреса маршрутов",
|
||||
"interfaceNameMode": "Исходящий интерфейс",
|
||||
"interfaceNameModeClear": "Очистить",
|
||||
"interfaceNameModeFollow": "Как в конфигурации",
|
||||
@@ -293,6 +350,20 @@
|
||||
"interfaceNameDesc": "Сетевой интерфейс для исходящих соединений",
|
||||
"copyEnvVar": "Копировать переменные окружения",
|
||||
"memoryInfo": "Память",
|
||||
"total": "Всего",
|
||||
"releaseMemory": "Освободить память",
|
||||
"memoryCoreHeapInuse": "Используемая куча",
|
||||
"memoryCoreHeapIdle": "Свободная куча",
|
||||
"memoryCoreStack": "Стеки горутин",
|
||||
"memoryCoreRuntime": "Накладные расходы среды",
|
||||
"memoryCoreNotRunning": "Ядро не запущено",
|
||||
"memoryAppResident": "Резидентная память",
|
||||
"memoryAppShared": "Приложение и общая",
|
||||
"memoryEstimateDesc": "Оценка по резидентной памяти процессов; может отличаться от данных системы.",
|
||||
"memoryEstimateSharedDesc": "Ядро работает в процессе приложения. Его доля оценивается по статистике среды выполнения, остальное относится к приложению и общей памяти.",
|
||||
"memoryReleased": "Память освобождена",
|
||||
"memoryReleasedSize": "Освобождено {size}",
|
||||
"releaseMemoryFailed": "Не удалось освободить память",
|
||||
"cancel": "Отмена",
|
||||
"fileIsUpdate": "Файл изменён. Сохранить изменения?",
|
||||
"profileHasUpdate": "Профиль изменён. Отключить автообновление?",
|
||||
@@ -305,27 +376,27 @@
|
||||
"listen": "Прослушивание",
|
||||
"undo": "Отменить",
|
||||
"redo": "Повторить",
|
||||
"lineWrap": "Перенос строк",
|
||||
"none": "Нет",
|
||||
"basicConfig": "Базовая конфигурация",
|
||||
"basicConfigDesc": "Глобальное изменение базовой конфигурации",
|
||||
"advancedConfig": "Расширенная конфигурация",
|
||||
"advancedConfigDesc": "Разнообразные параметры конфигурации",
|
||||
"advancedConfigDesc": "Сеть, DNS, добавленные правила и скрипты",
|
||||
"selectedCountTitle": "Выбрано: {count}",
|
||||
"addRule": "Добавить правило",
|
||||
"ruleName": "Название правила",
|
||||
"content": "Содержимое",
|
||||
"subRule": "Подправило",
|
||||
"ruleTarget": "Цель правила",
|
||||
"matchTargetTitle": "Цель MATCH",
|
||||
"matchTarget": "MATCH-TARGET",
|
||||
"followProfile": "Как в профиле",
|
||||
"matchTargetDesc": "Куда направляются правила с целью MATCH-TARGET. По умолчанию — цель последнего правила MATCH этого профиля.",
|
||||
"selectMatchTarget": "Выбрать MATCH-TARGET",
|
||||
"sourceIp": "IP источника",
|
||||
"noResolve": "Не разрешать IP",
|
||||
"saveChanges": "Сохранить изменения?",
|
||||
"findProcessModeDesc": "При включении возможна небольшая потеря производительности",
|
||||
"tabAnimationDesc": "Действует только в мобильном виде",
|
||||
"navigationBarStyle": "Нижняя панель",
|
||||
"floating": "Плавающая",
|
||||
"docked": "Закреплённая",
|
||||
"slide": "Сдвиг",
|
||||
"fade": "Растворение",
|
||||
"pureBlack": "Чисто чёрный",
|
||||
"colorSchemes": "Цветовые схемы",
|
||||
"palette": "Палитра",
|
||||
"tonalSpotScheme": "Тональный акцент",
|
||||
@@ -348,6 +419,8 @@
|
||||
"launchInterruptedTip": "В прошлый раз приложение неожиданно завершилось во время запуска. Автоматическая настройка для этого запуска пропущена; вы можете запустить её вручную.",
|
||||
"clearData": "Очистить данные",
|
||||
"textScale": "Масштаб текста",
|
||||
"textScalePreview": "Так будет выглядеть текст в приложении",
|
||||
"followSystem": "Как в системе",
|
||||
"internet": "Интернет",
|
||||
"systemApp": "Системные приложения",
|
||||
"noNetworkApp": "Приложения без сети",
|
||||
@@ -361,7 +434,13 @@
|
||||
"interval": "Интервал",
|
||||
"existsTip": "«{label}» уже существует",
|
||||
"maxLengthTip": "«{label}» — не более {max} символов",
|
||||
"multipleValuesTip": "Разделяйте несколько значений запятыми",
|
||||
"singleAdd": "По одному",
|
||||
"batchAdd": "Массовое добавление",
|
||||
"batchListInputTip": "По одному значению на строку или через запятую",
|
||||
"batchMapInputTip": "По одной записи на строку: ключ, пробел, значение",
|
||||
"batchPreviewTip": "Будет добавлено: {count}, пропущено (уже есть): {skipped}",
|
||||
"lineIssueTip": "Строка {line}: {message}",
|
||||
"singleValueTip": "«{label}» — только одно значение",
|
||||
"deleteTip": "Вы уверены, что хотите удалить «{label}»?",
|
||||
"deleteMultipTip": "Вы уверены, что хотите удалить выбранные элементы ({label})?",
|
||||
"nullTip": "Пока нет: {label}",
|
||||
@@ -378,6 +457,7 @@
|
||||
"import": "Импорт",
|
||||
"importFile": "Импорт из файла",
|
||||
"importUrl": "Импорт по URL",
|
||||
"startFromScratch": "С нуля",
|
||||
"autoSetSystemDns": "Автонастройка системного DNS",
|
||||
"details": "Сведения: {label}",
|
||||
"creationTime": "Время создания",
|
||||
@@ -396,7 +476,7 @@
|
||||
"request": "Запрос",
|
||||
"connected": "Подключено",
|
||||
"disconnected": "Отключено",
|
||||
"connecting": "Подключение...",
|
||||
"connecting": "Подключение…",
|
||||
"restartCoreTip": "Вы уверены, что хотите перезапустить ядро?",
|
||||
"forceRestartCoreTip": "Вы уверены, что хотите принудительно перезапустить ядро?",
|
||||
"dnsHijacking": "Перехват DNS",
|
||||
@@ -406,20 +486,25 @@
|
||||
"crashlytics": "Аналитика сбоев",
|
||||
"crashlyticsTip": "При включении в случае сбоя приложения автоматически загружаются логи сбоя без конфиденциальной информации",
|
||||
"appendSystemDns": "Добавлять системный DNS",
|
||||
"appendSystemDnsTip": "Принудительно добавлять системный DNS в конфигурацию",
|
||||
"editRule": "Редактировать правило",
|
||||
"overrideMode": "Режим переопределения",
|
||||
"standardModeDesc": "Стандартный режим: переопределяет базовую конфигурацию и позволяет просто добавлять правила",
|
||||
"scriptModeDesc": "Режим скрипта: использует внешние скрипты-расширения для переопределения конфигурации в один клик",
|
||||
"addedRules": "Добавленные правила",
|
||||
"controlGlobalAddedRules": "Управление глобальными добавленными правилами",
|
||||
"quickAdd": "Быстрое добавление",
|
||||
"rulePresetBlockQuic": "Блокировать QUIC",
|
||||
"rulePresetBlockStun": "Блокировать STUN",
|
||||
"rulePresetBlockDot": "Блокировать DNS over TLS",
|
||||
"rulePresetSystemServicesDirect": "Apple и Microsoft напрямую",
|
||||
"rulePresetBittorrentDirect": "BitTorrent напрямую",
|
||||
"rulePresetLanDirect": "Локальная сеть напрямую",
|
||||
"overrideScript": "Скрипт переопределения",
|
||||
"goToConfigureScript": "Перейти к настройке скрипта",
|
||||
"editGlobalRules": "Редактировать глобальные правила",
|
||||
"externalFetch": "Внешнее получение",
|
||||
"confirmForceCrashCore": "Вы уверены, что хотите принудительно завершить ядро со сбоем?",
|
||||
"confirmClearAllData": "Вы уверены, что хотите удалить все данные?",
|
||||
"loading": "Загрузка...",
|
||||
"loading": "Загрузка…",
|
||||
"yearsAgo": "{count, plural, one{{count} год назад} few{{count} года назад} many{{count} лет назад} other{{count} года назад}}",
|
||||
"monthsAgo": "{count, plural, one{{count} месяц назад} few{{count} месяца назад} many{{count} месяцев назад} other{{count} месяца назад}}",
|
||||
"daysAgo": "{count, plural, one{{count} день назад} few{{count} дня назад} many{{count} дней назад} other{{count} дня назад}}",
|
||||
@@ -435,24 +520,31 @@
|
||||
"speedStatistics": "Статистика скорости",
|
||||
"resetPageChangesTip": "На этой странице есть изменения. Вы уверены, что хотите выполнить сброс?",
|
||||
"overwriteTypeCustom": "Пользовательский",
|
||||
"overwriteTypeCustomDesc": "Пользовательский режим: полная настройка групп прокси и правил",
|
||||
"overwriteTypeCustomDesc": "Пользовательский режим: полная настройка прокси, групп прокси и правил",
|
||||
"unknownNetworkError": "Неизвестная сетевая ошибка",
|
||||
"restoreException": "Ошибка восстановления",
|
||||
"networkException": "Ошибка сети. Проверьте подключение и повторите попытку",
|
||||
"backupFromNewerVersion": "Резервная копия создана более новой версией приложения. Обновите приложение перед восстановлением",
|
||||
"networkTimeoutError": "Время ожидания запроса истекло. Проверьте сеть или прокси и повторите попытку",
|
||||
"networkHostLookupError": "Не удалось определить адрес сервера. Проверьте правильность URL и работу DNS",
|
||||
"networkConnectionError": "Не удалось подключиться к серверу. Проверьте подключение к сети или настройки прокси",
|
||||
"networkTlsError": "Не удалось установить защищённое соединение. Сертификат сервера может быть недействителен, или соединение перехватывается",
|
||||
"networkCancelledError": "Запрос отменён",
|
||||
"networkAccessDeniedError": "Сервер запретил доступ (HTTP {code}). Возможно, ссылка устарела или учётные данные неверны",
|
||||
"networkNotFoundError": "По этому адресу ничего не найдено (HTTP {code}). Проверьте правильность URL",
|
||||
"networkRateLimitedError": "Слишком много запросов (HTTP 429). Подождите немного и повторите попытку",
|
||||
"networkServerError": "На сервере произошла ошибка (HTTP {code}). Повторите попытку позже",
|
||||
"networkBadResponseError": "Сервер отклонил запрос (HTTP {code})",
|
||||
"networkRequestFailed": "Сетевой запрос не выполнен: {detail}",
|
||||
"invalidBackupFile": "Недопустимый файл резервной копии",
|
||||
"pruneCache": "Очистить кэш",
|
||||
"backupAndRestore": "Резервное копирование и восстановление",
|
||||
"backupAndRestoreDesc": "Синхронизация данных через WebDAV или файлы",
|
||||
"restore": "Восстановить",
|
||||
"restoreSuccess": "Восстановление выполнено",
|
||||
"restoreFromWebDAVDesc": "Восстановить данные из WebDAV",
|
||||
"restoreFromFileDesc": "Восстановить данные из файла",
|
||||
"restoreOnlyConfig": "Восстановить только профили",
|
||||
"restoreAllData": "Восстановить все данные",
|
||||
"addProfile": "Добавить профиль",
|
||||
"delayTest": "Тест задержки",
|
||||
"proxyGroupEmpty": "Группа прокси пуста",
|
||||
"proxyGroupNameEmpty": "Название группы прокси не может быть пустым",
|
||||
"proxyGroupNameDuplicate": "Название группы прокси уже используется",
|
||||
"confirmExitWindow": "Вы уверены, что хотите закрыть текущее окно?",
|
||||
"dataChangedSave": "Обнаружены изменения данных. Сохранить их?",
|
||||
@@ -464,6 +556,8 @@
|
||||
"excludeProxyFilter": "Фильтр исключения узлов",
|
||||
"excludeType": "Исключаемые типы",
|
||||
"expectedStatus": "Ожидаемый статус",
|
||||
"strategy": "Стратегия",
|
||||
"tolerance": "Допуск",
|
||||
"selectProxies": "Выбрать прокси",
|
||||
"inputProxyGroupName": "Введите название группы прокси",
|
||||
"helperCorruptTip": "Служба Helper недоступна, поэтому TUN-режим включить нельзя. Переустановите FlClash.",
|
||||
@@ -488,7 +582,7 @@
|
||||
"basicStrategy": "Базовые политики",
|
||||
"editProxy": "Редактировать прокси",
|
||||
"includeAllProxyProviders": "Включить всех провайдеров прокси",
|
||||
"includeAllProxyProvidersTip": "При включении переопределяет подключённых провайдеров прокси",
|
||||
"includeAllProxyProvidersTip": "При включении группа берёт всех провайдеров прокси этого профиля: собственных провайдеров подписки, а также профили и прокси-провайдеры приложения, которые использует любая группа прокси",
|
||||
"addProxyProviders": "Добавить провайдеров прокси",
|
||||
"includeAllProxies": "Включить все прокси",
|
||||
"includeAllProxiesTip": "Подключает все прокси вне групп; ниже можно добавить дополнительные группы прокси",
|
||||
@@ -550,11 +644,11 @@
|
||||
"invalidSubRule": "{subRule} — недопустимый SUB_RULE",
|
||||
"invalidPolicy": "{target} — недопустимая политика",
|
||||
"invalidProxyProvider": "{providerName} — недопустимый провайдер прокси",
|
||||
"invalidRuleSet": "{ruleSet} — недопустимый набор правил",
|
||||
"invalidNetworkContent": "Поддерживаются только tcp и udp",
|
||||
"invalidRangeContent": "Введите числа или диапазоны, например 80 или 8000-9000, через /",
|
||||
"invalidDscpContent": "Метка DSCP не может превышать 63",
|
||||
"invalidProxy": "{proxyName} — недопустимый прокси",
|
||||
"proxyGroupDetectedAbnormal": "Обнаружены отклонения в текущей группе прокси",
|
||||
"proxyProviderDetectedAbnormal": "Обнаружены отклонения в выбранных провайдерах прокси",
|
||||
"proxyDetectedAbnormal": "Обнаружены отклонения в выбранных прокси",
|
||||
"createProfile": "Создать профиль",
|
||||
"locationPermissionRequired": "Требуется разрешение на геолокацию",
|
||||
"locationPermissionGuide": "1. Откройте Системные настройки > Конфиденциальность и безопасность\n2. Выберите Службы геолокации\n3. Найдите и отметьте {appName} в списке\n\nПосле настройки вернитесь в приложение и продолжайте работу. Спасибо за сотрудничество.",
|
||||
"prerequisites": "Предварительные условия",
|
||||
@@ -573,7 +667,7 @@
|
||||
"editSsid": "Изменить SSID",
|
||||
"authorized": "Разрешено",
|
||||
"tapToAuthorize": "Нажмите, чтобы разрешить",
|
||||
"suspended": "Приостановлено...",
|
||||
"suspended": "Приостановлено…",
|
||||
"geoOptions": "Настройки Geo",
|
||||
"geoAutoUpdate": "Автообновление",
|
||||
"geoAutoUpdateInterval": "Интервал автообновления",
|
||||
@@ -584,7 +678,6 @@
|
||||
"geoSkipped": "{name}: уже последняя версия",
|
||||
"geoUpdated": "{name}: обновлено",
|
||||
"secondsCount": "{count, plural, one{{count} секунда} few{{count} секунды} many{{count} секунд} other{{count} секунды}}",
|
||||
"entriesCount": "{count, plural, one{{count} запись} few{{count} записи} many{{count} записей} other{{count} записи}}",
|
||||
"proxiesCount": "{count} прокси",
|
||||
"rulesCount": "{count, plural, one{{count} правило} few{{count} правила} many{{count} правил} other{{count} правила}}",
|
||||
"changelogBreaking": "Важные изменения",
|
||||
@@ -600,7 +693,6 @@
|
||||
"pinWindow": "Закрепить поверх всех окон",
|
||||
"unpinWindow": "Открепить окно",
|
||||
"exitFullScreen": "Выйти из полноэкранного режима",
|
||||
"toggleLabel": "Переключить подписи",
|
||||
"torch": "Фонарик",
|
||||
"pickFromAlbum": "Выбрать из галереи",
|
||||
"blockConnection": "Заблокировать соединение",
|
||||
@@ -611,11 +703,79 @@
|
||||
"previousMatch": "Предыдущее совпадение",
|
||||
"nextMatch": "Следующее совпадение",
|
||||
"clearSearch": "Очистить поиск",
|
||||
"noSearchResults": "Ничего не найдено",
|
||||
"addWidget": "Добавить виджет",
|
||||
"showPassword": "Показать пароль",
|
||||
"hidePassword": "Скрыть пароль",
|
||||
"authorize": "Разрешить",
|
||||
"installedAppsPermissionRequired": "Требуется разрешение на список приложений",
|
||||
"installedAppsPermissionDesc": "Эта система не выдаёт список установленных приложений без разрешения. Предоставьте его, чтобы настроить прокси для отдельных приложений.",
|
||||
"installedAppsPermissionDeniedMessage": "Разрешение на список приложений отклонено, поэтому установленные приложения недоступны. Предоставьте его вручную в системных настройках."
|
||||
"installedAppsPermissionDeniedMessage": "Разрешение на список приложений отклонено, поэтому установленные приложения недоступны. Предоставьте его вручную в системных настройках.",
|
||||
"outboundIp": "Исходящий IP",
|
||||
"ipAddress": "IP-адрес",
|
||||
"ipQualityLevel": "Уровень",
|
||||
"ipQualityGood": "Хороший",
|
||||
"ipQualityNormal": "Обычный",
|
||||
"ipQualityRisky": "Рискованный",
|
||||
"ipType": "Тип",
|
||||
"ipTypeResidential": "Домашний",
|
||||
"ipTypeMobile": "Мобильная сеть",
|
||||
"ipTypeBusiness": "Бизнес",
|
||||
"ipTypeHosting": "Дата-центр",
|
||||
"ipFlags": "Метки",
|
||||
"ipFlagProxy": "Прокси",
|
||||
"ipFlagVpn": "VPN",
|
||||
"ipFlagTor": "Tor",
|
||||
"ipFlagAbuser": "Злоупотребления",
|
||||
"ipOrganization": "Организация",
|
||||
"ipAsn": "ASN",
|
||||
"ipQualitySource": "Источник ответа",
|
||||
"ipQualitySources": "Источники",
|
||||
"ipQualityFailed": "Не удалось определить тип IP",
|
||||
"ipQualityRetry": "Проверить снова",
|
||||
"ipSourceNoType": "Тип не определён",
|
||||
"ipSourceRateLimited": "Лимит запросов",
|
||||
"ipSourceIpMismatch": "Другой исходящий IP",
|
||||
"hideIp": "Скрыть IP",
|
||||
"editorUnavailable": "Редактор недоступен",
|
||||
"replace": "Заменить",
|
||||
"replaceAll": "Заменить все",
|
||||
"cameraPermissionRequired": "Требуется доступ к камере",
|
||||
"cameraPermissionDesc": "Разрешите доступ к камере в системных настройках, чтобы сканировать QR-коды, или выберите изображение QR-кода из галереи.",
|
||||
"cameraUnavailable": "Камера недоступна",
|
||||
"retry": "Повторить",
|
||||
"invalidProfileQrcode": "Этот QR-код не содержит ссылку на профиль",
|
||||
"dnsQueries": "DNS-запросы",
|
||||
"recordType": "Тип записи",
|
||||
"answers": "Ответы",
|
||||
"responseCode": "Код ответа",
|
||||
"error": "Ошибка",
|
||||
"initiator": "Инициатор",
|
||||
"cache": "Кэш",
|
||||
"proxyNode": "Прокси-узел",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"nonTextProviderFile": "Этот внешний ресурс не является текстовым файлом",
|
||||
"addCustomProxy": "Добавить прокси",
|
||||
"proxyDefinition": "Полная конфигурация",
|
||||
"proxyDefinitionNotMap": "Конфигурация должна быть YAML-словарём с полями name и type",
|
||||
"customProxiesEmpty": "Пользовательских прокси нет, поэтому используются прокси самого профиля",
|
||||
"overwriteIssueEmptyName": "Имя не задано",
|
||||
"overwriteIssueReservedName": "{name} — встроенное имя политики, его нельзя использовать",
|
||||
"overwriteIssueDuplicateName": "Имя {name} уже занято другим прокси или группой прокси",
|
||||
"overwriteIssueCoreRejected": "Ядро не может разобрать этот прокси: {message}",
|
||||
"overwriteIssueMissingProxies": "Эти прокси или политики не существуют: {names}",
|
||||
"overwriteIssueMissingProviders": "Эти провайдеры прокси не существуют: {names}",
|
||||
"overwriteIssueNoProxySource": "Не выбраны ни прокси, ни провайдеры прокси, поэтому ядро отклонит эту группу",
|
||||
"overwriteIssueGroupLoop": "Группы прокси ссылаются друг на друга по кругу: {path}",
|
||||
"overwriteIssuesSummary": "Проблем: {count}, применение переопределения может завершиться ошибкой",
|
||||
"overwriteIssueSubscriptionGroupMissingProxies": "Собственные группы прокси профиля ссылаются на прокси, которых больше нет среди пользовательских: {names}",
|
||||
"providerInUse": "«{label}» всё ещё используется в пользовательских группах прокси или правилах профилей: {profiles}. Сначала уберите его оттуда",
|
||||
"providerSourceSubscription": "Подписка",
|
||||
"providerRenameShadowed": "В подписках профилей {profiles} уже есть «{label}», и после переименования они будут использовать его. Выберите другое имя",
|
||||
"fontSize": "Размер",
|
||||
"large": "Крупный",
|
||||
"extraLarge": "Очень крупный",
|
||||
"switchProfile": "Сменить профиль",
|
||||
"lastUpdated": "Последнее обновление"
|
||||
}
|
||||
|
||||
@@ -9,19 +9,32 @@
|
||||
"profiles": "配置",
|
||||
"tools": "工具",
|
||||
"logs": "日志",
|
||||
"logsDesc": "日志捕获记录",
|
||||
"resources": "资源",
|
||||
"resourcesDesc": "外部资源相关信息",
|
||||
"trafficUsage": "流量统计",
|
||||
"networkSpeed": "网络速度",
|
||||
"outboundMode": "出站模式",
|
||||
"networkDetection": "网络检测",
|
||||
"serviceStatus": "服务状态",
|
||||
"serviceRestricted": "访问受限",
|
||||
"serviceAvailable": "可用",
|
||||
"serviceUnavailable": "不可用",
|
||||
"serviceFailed": "检测失败",
|
||||
"serviceDisallowedIsp": "不允许的 ISP",
|
||||
"serviceBlocked": "已被封禁",
|
||||
"serviceUnsupportedRegion": "地区不支持",
|
||||
"serviceOriginalsOnly": "仅限自制内容",
|
||||
"serviceComingSoon": "即将上线",
|
||||
"servicePending": "待检测",
|
||||
"serviceManage": "管理服务",
|
||||
"serviceCheck": "检测",
|
||||
"serviceCheckAll": "全部检测",
|
||||
"serviceCheckedAt": "检测于 {time}",
|
||||
"upload": "上传",
|
||||
"download": "下载",
|
||||
"usedTraffic": "已用流量",
|
||||
"totalTraffic": "总流量",
|
||||
"expireTime": "到期时间",
|
||||
"nullProfileDesc": "没有配置文件,请先添加配置文件",
|
||||
"nullProfileDesc": "添加一个配置文件后即可开始使用",
|
||||
"settings": "设置",
|
||||
"language": "语言",
|
||||
"defaultText": "默认",
|
||||
@@ -36,27 +49,22 @@
|
||||
"themeDesc": "设置深色模式,调整色彩",
|
||||
"override": "覆写",
|
||||
"allowLan": "局域网代理",
|
||||
"allowLanDesc": "允许通过局域网访问代理",
|
||||
"tun": "虚拟网卡",
|
||||
"tunDesc": "仅在管理员模式生效",
|
||||
"minimizeOnExit": "退出时最小化",
|
||||
"minimizeOnExitDesc": "修改系统默认退出事件",
|
||||
"autoLaunch": "自启动",
|
||||
"autoLaunchDesc": "跟随系统自启动",
|
||||
"silentLaunch": "静默启动",
|
||||
"silentLaunchDesc": "后台启动",
|
||||
"silentLaunchDesc": "启动时不显示窗口",
|
||||
"autoRun": "自动运行",
|
||||
"autoRunDesc": "应用打开时自动运行",
|
||||
"logcat": "日志捕获",
|
||||
"logcatDesc": "禁用将会隐藏日志入口",
|
||||
"autoCheckUpdate": "自动检查更新",
|
||||
"autoCheckUpdateDesc": "应用启动时自动检查更新",
|
||||
"checkCertificate": "校验 TLS 证书",
|
||||
"checkCertificateDesc": "拒绝不受信任的证书。关闭后订阅和备份将暴露于中间人攻击",
|
||||
"accessControl": "访问控制",
|
||||
"accessControlDesc": "配置应用访问代理",
|
||||
"application": "应用程序",
|
||||
"applicationDesc": "修改应用程序相关设置",
|
||||
"edit": "编辑",
|
||||
"confirm": "确定",
|
||||
"update": "更新",
|
||||
@@ -103,12 +111,15 @@
|
||||
"show": "显示",
|
||||
"exit": "退出",
|
||||
"systemProxy": "系统代理",
|
||||
"safeMode": "安全模式",
|
||||
"safeModeAppTitle": "{appName}(安全模式)",
|
||||
"project": "项目",
|
||||
"core": "内核",
|
||||
"tabAnimation": "选项卡动画",
|
||||
"desc": "基于ClashMeta的多平台代理客户端,简单易用,开源无广告。",
|
||||
"startVpn": "正在启动VPN...",
|
||||
"stopVpn": "正在停止VPN...",
|
||||
"startVpn": "正在启动VPN…",
|
||||
"stopVpn": "正在停止VPN…",
|
||||
"localNetworkDeniedTip": "本地网络权限被拒绝:已改用 gvisor 栈,局域网无法访问。",
|
||||
"compatible": "兼容模式",
|
||||
"notSelectedTip": "当前代理组无法选中",
|
||||
"changeProxyFailedTip": "切换代理失败,已恢复上一次的选择",
|
||||
@@ -137,37 +148,36 @@
|
||||
"country": "区域",
|
||||
"search": "搜索",
|
||||
"allowBypass": "允许应用绕过VPN",
|
||||
"allowBypassDesc": "开启后部分应用可绕过VPN",
|
||||
"externalController": "外部控制器",
|
||||
"externalControllerDesc": "开启后将可以通过9090端口控制Clash内核",
|
||||
"ipv6Desc": "开启后将可以接收IPv6流量",
|
||||
"app": "应用",
|
||||
"general": "常规",
|
||||
"systemProxyDesc": "设置系统代理",
|
||||
"startupAndBackground": "启动与后台",
|
||||
"requestsAndUpdates": "请求与更新",
|
||||
"logsAndDiagnostics": "日志与诊断",
|
||||
"userAgent": "用户代理",
|
||||
"unifiedDelay": "统一延迟",
|
||||
"unifiedDelayDesc": "去除握手等额外延迟",
|
||||
"tcpConcurrent": "TCP并发",
|
||||
"tcpConcurrentDesc": "开启后允许TCP并发",
|
||||
"geodataLoader": "Geo低内存模式",
|
||||
"geodataLoaderDesc": "开启将使用Geo低内存加载器",
|
||||
"requests": "请求",
|
||||
"requestsDesc": "查看最近请求记录",
|
||||
"recentRequests": "最近请求",
|
||||
"findProcessMode": "查找进程",
|
||||
"init": "初始化",
|
||||
"infiniteTime": "长期有效",
|
||||
"connections": "连接",
|
||||
"liveConnections": "实时连接",
|
||||
"inbound": "入站",
|
||||
"connectionsDesc": "查看当前连接数据",
|
||||
"intranetIP": "内网 IP",
|
||||
"view": "查看",
|
||||
"cut": "剪切",
|
||||
"copy": "复制",
|
||||
"clipboardWriteFailed": "无法复制到剪贴板,所选内容可能过大",
|
||||
"paste": "粘贴",
|
||||
"testUrl": "测速链接",
|
||||
"sync": "同步",
|
||||
"exclude": "从最近任务中隐藏",
|
||||
"excludeDesc": "应用在后台时,从最近任务中隐藏应用",
|
||||
"excludeDesc": "应用在后台时,从最近任务中隐藏应用",
|
||||
"expand": "标准",
|
||||
"shrink": "紧凑",
|
||||
"min": "最小",
|
||||
@@ -182,19 +192,18 @@
|
||||
"go": "前往",
|
||||
"externalLink": "外部链接",
|
||||
"otherContributors": "其他贡献者",
|
||||
"appIconDesign": "应用图标设计",
|
||||
"autoCloseConnections": "自动关闭连接",
|
||||
"autoCloseConnectionsDesc": "切换节点后自动关闭连接",
|
||||
"onlyStatisticsProxy": "仅统计代理",
|
||||
"onlyStatisticsProxyDesc": "开启后,将只统计代理流量",
|
||||
"onlyStatisticsProxy": "仅统计代理流量",
|
||||
"showNotificationStopAction": "通知栏显示停止按钮",
|
||||
"showNotificationStopActionDesc": "在常驻通知上显示停止按钮。若系统因此总是展开通知,可关闭",
|
||||
"pureBlackMode": "纯黑模式",
|
||||
"sidebarBlur": "侧边栏背景模糊",
|
||||
"sidebarBlurDesc": "侧边栏透出窗口后方模糊的桌面",
|
||||
"keepAliveIntervalDesc": "TCP保持活动间隔",
|
||||
"entries": "个条目",
|
||||
"local": "本地",
|
||||
"remote": "远程",
|
||||
"remoteBackupDesc": "备份数据到WebDAV",
|
||||
"localBackupDesc": "备份数据到本地",
|
||||
"mode": "模式",
|
||||
"time": "时间",
|
||||
"source": "来源",
|
||||
@@ -208,73 +217,122 @@
|
||||
"loose": "宽松",
|
||||
"profilesSort": "配置排序",
|
||||
"start": "启动",
|
||||
"runTime": "启动时间",
|
||||
"stop": "暂停",
|
||||
"dnsDesc": "更新DNS相关设置",
|
||||
"key": "键",
|
||||
"value": "值",
|
||||
"hostsDesc": "追加Hosts",
|
||||
"vpnTip": "重启VPN后改变生效",
|
||||
"vpnEnableDesc": "通过VpnService自动路由系统所有流量",
|
||||
"options": "选项",
|
||||
"loopback": "回环解锁工具",
|
||||
"loopbackDesc": "用于UWP回环解锁",
|
||||
"enabled": "已开启",
|
||||
"disabled": "已关闭",
|
||||
"loopback": "UWP 回环解锁",
|
||||
"providers": "外部资源",
|
||||
"proxyProviders": "代理集",
|
||||
"ruleProviders": "规则集",
|
||||
"behavior": "行为",
|
||||
"format": "格式",
|
||||
"providerUrlTip": "仅支持远程资源",
|
||||
"subscriptionInfo": "订阅信息",
|
||||
"overrideDns": "覆写DNS",
|
||||
"overrideDnsDesc": "开启后将覆盖配置中的DNS选项",
|
||||
"overrideNtp": "覆写NTP",
|
||||
"ntpStatusDesc": "从NTP服务器获取时间,而非系统时钟",
|
||||
"server": "服务器",
|
||||
"ntpInterval": "同步间隔(分钟)",
|
||||
"dialerProxy": "拨号代理",
|
||||
"dialerProxyDesc": "用于连接NTP服务器的出站",
|
||||
"writeToSystem": "写入系统",
|
||||
"writeToSystemDesc": "同时设置系统时钟,Android上不生效",
|
||||
"overrideEntries": "覆写项",
|
||||
"addOverrideEntry": "添加覆写项",
|
||||
"discardChanges": "是否放弃更改?",
|
||||
"quickEdit": "快速编辑",
|
||||
"status": "状态",
|
||||
"statusDesc": "关闭后将使用系统DNS",
|
||||
"preferH3Desc": "优先使用DOH的http/3",
|
||||
"respectRules": "遵守规则",
|
||||
"respectRulesDesc": "DNS连接跟随rules,需配置proxy-server-nameserver",
|
||||
"respectRulesDesc": "DNS连接遵守规则,需配置Proxy Server Nameserver",
|
||||
"dnsMode": "DNS模式",
|
||||
"fakeipRange": "Fakeip范围",
|
||||
"fakeipFilter": "Fakeip过滤",
|
||||
"defaultNameserver": "默认域名服务器",
|
||||
"defaultNameserverDesc": "用于解析DNS服务器",
|
||||
"nameserver": "域名服务器",
|
||||
"nameserverDesc": "用于解析域名",
|
||||
"fakeipRange": "Fake-IP范围",
|
||||
"fakeipFilter": "Fake-IP过滤",
|
||||
"useHosts": "使用Hosts",
|
||||
"useSystemHosts": "使用系统Hosts",
|
||||
"nameserverPolicy": "域名服务器策略",
|
||||
"nameserverPolicyDesc": "指定对应域名服务器策略",
|
||||
"proxyNameserver": "代理域名服务器",
|
||||
"proxyNameserverDesc": "用于解析代理节点的域名",
|
||||
"fallback": "Fallback",
|
||||
"fallbackDesc": "一般情况下使用境外DNS",
|
||||
"fallbackFilter": "Fallback过滤",
|
||||
"geoipCode": "Geoip代码",
|
||||
"ipcidr": "IP/掩码",
|
||||
"domain": "域名",
|
||||
"listenRoutingMark": "监听路由标记",
|
||||
"listenRoutingMarkDesc": "仅Linux有效",
|
||||
"ipv6Timeout": "IPv6超时(毫秒)",
|
||||
"cacheAlgorithm": "缓存算法",
|
||||
"cacheMaxSize": "缓存大小",
|
||||
"fakeipRange6": "Fake-IP范围(IPv6)",
|
||||
"fakeipFilterMode": "Fake-IP过滤模式",
|
||||
"fakeipFilterModeDesc": "blacklist排除匹配项,whitelist仅用于匹配项,rule按规则匹配",
|
||||
"fakeipTtl": "Fake-IP TTL",
|
||||
"reset": "重置",
|
||||
"actionView": "显示/隐藏",
|
||||
"actionStart": "启动/停止",
|
||||
"actionMode": "切换模式",
|
||||
"actionProxy": "系统代理",
|
||||
"actionTun": "虚拟网卡",
|
||||
"actionRuleMode": "规则模式",
|
||||
"actionGlobalMode": "全局模式",
|
||||
"actionDirectMode": "直连模式",
|
||||
"actionDelayTest": "测试全部延迟",
|
||||
"actionUpdateProfiles": "更新全部配置",
|
||||
"disclaimer": "免责声明",
|
||||
"disclaimerDesc": "本软件仅供学习交流、科研等非商业性质的用途,严禁将本软件用于商业目的。如有任何商业行为,均与本软件无关。",
|
||||
"disclaimerDesc": "在使用 FlClash(以下简称“本软件”)前,请仔细阅读并充分理解本声明的全部内容。点击“同意”即表示你已阅读、理解并接受以下全部条款;如不同意,请点击“退出”并停止使用本软件。",
|
||||
"disclaimerSoftwareTitle": "软件性质",
|
||||
"disclaimerSoftwareContent": "本软件是基于 Clash.Meta(mihomo)内核的开源网络代理客户端,仅提供配置管理、规则分流与流量转发等本地工具功能。\n\n本软件自身不提供任何代理服务器、节点、订阅、机场或网络接入服务,也不与任何此类服务的提供者存在合作、代理或担保关系。",
|
||||
"disclaimerUsageTitle": "使用限制",
|
||||
"disclaimerUsageContent": "本软件仅供学习交流、技术研究等非商业用途。严禁将本软件用于任何商业目的,包括但不限于付费分发、捆绑销售、作为商业服务的组成部分或以本软件名义开展经营活动;任何商业行为均与本软件及其开发者无关。\n\n严禁将本软件用于违反你所在国家或地区法律法规的活动,包括但不限于绕过依法设置的网络访问限制、传播违法信息、实施网络攻击或侵犯他人合法权益。",
|
||||
"disclaimerResponsibilityTitle": "用户责任",
|
||||
"disclaimerResponsibilityContent": "你应自行确认使用本软件在你所在国家或地区是合法的,并对使用本软件的全部行为及其后果独立承担法律责任。\n\n你导入的订阅、节点与配置由你自行选择和判断,其来源是否合法、内容是否安全、服务是否稳定,均由你与相关提供者自行负责。",
|
||||
"disclaimerThirdPartyTitle": "第三方内容",
|
||||
"disclaimerThirdPartyContent": "订阅链接、配置文件、规则集、脚本、外部资源及外部链接均由第三方提供,开发者无法也不会对其合法性、准确性、安全性和可用性进行审查或担保。\n\n因使用第三方内容导致的数据泄露、财产损失、账号封禁或其他任何损失,由你与第三方自行解决,开发者不承担任何责任。",
|
||||
"disclaimerWarrantyTitle": "无担保声明",
|
||||
"disclaimerWarrantyContent": "本软件按“现状”和“可用”的原则提供,不附带任何明示或默示的担保,包括但不限于适销性、特定用途适用性、不侵权以及持续可用、无错误、无安全漏洞的担保。\n\n开发者不保证本软件能满足你的需求,也不保证本软件的运行不会中断或不会出现错误。",
|
||||
"disclaimerLiabilityTitle": "责任限制",
|
||||
"disclaimerLiabilityContent": "在适用法律允许的最大范围内,开发者及所有贡献者均不对因使用或无法使用本软件而产生的任何直接、间接、偶然、特殊、惩罚性或后果性损害承担责任,包括但不限于数据丢失、设备损坏、网络异常、业务中断、利润损失或因此引起的任何法律纠纷,即使已被告知发生此类损害的可能性。",
|
||||
"disclaimerPrivacyTitle": "数据收集与隐私",
|
||||
"disclaimerPrivacyContent": "本软件不会收集或上传你的订阅地址、节点信息、配置内容、访问的网站、连接记录、流量内容和日志,这些数据仅保存在你的设备本地,开发者无法获取。\n\n本软件只会在你使用相应功能时访问外部网络,例如更新订阅时访问你提供的订阅地址、检查更新时访问 GitHub。\n\n桌面版(Windows、macOS、Linux)未集成任何数据统计或崩溃上报服务。Android 版集成了 Google Firebase 的以下两项服务,用于改进应用稳定性:",
|
||||
"disclaimerCrashlyticsTitle": "Firebase Crashlytics(崩溃分析)",
|
||||
"disclaimerCrashlyticsContent": "应用发生崩溃时,自动上传崩溃报告。\n\n收集内容:崩溃堆栈与错误信息、崩溃发生时间、应用版本与构建号、设备品牌与型号、Android 系统版本、屏幕方向、剩余内存与存储空间、设备是否已 root,以及一个随安装生成、重装后即重置的随机安装标识。\n\n用途:仅用于定位和修复崩溃问题。\n\n你可以在“工具 > 常规 > 崩溃分析”中随时关闭。",
|
||||
"disclaimerAnalyticsTitle": "Firebase Analytics(使用统计)",
|
||||
"disclaimerAnalyticsContent": "随 Firebase 自动收集基础的应用使用统计。\n\n收集内容:首次启动、应用打开与会话时长、应用更新等基础事件,应用实例标识,设备型号、系统版本、系统语言,以及由 IP 地址推断的国家或地区级大致位置。\n\n用途:仅用于了解活跃设备数、版本分布与系统兼容情况。开发者不会将这些数据用于广告,不会出售,也不会与你的订阅或配置相关联。",
|
||||
"disclaimerDataProcessingContent": "上述数据由 Google 代为处理和存储,可能被传输至你所在国家或地区以外(如美国)的服务器,并受 Google 隐私政策与 Firebase 隐私和安全说明约束。崩溃报告最多保留 90 天,统计数据按 Firebase 的默认策略保留。",
|
||||
"disclaimerLicenseTitle": "开源协议",
|
||||
"disclaimerLicenseContent": "本软件以 GPL-3.0 协议开源。你可以在遵守该协议的前提下自由使用、修改和分发本软件,但须同样以 GPL-3.0 协议开源并保留原作者的版权声明。\n\n本软件包含的第三方组件(包括 Clash.Meta 内核)分别遵循其各自的开源协议。基于本软件修改或分发的版本所产生的任何问题,与原作者无关。",
|
||||
"disclaimerChangesTitle": "声明变更",
|
||||
"disclaimerChangesContent": "开发者有权随版本更新修改本声明,修改后的内容随新版本发布即生效。你在更新后继续使用本软件,即视为接受修改后的声明。",
|
||||
"disclaimerAcceptTitle": "接受声明",
|
||||
"disclaimerAcceptContent": "一旦你安装、复制或使用本软件,即视为你已阅读并同意本声明的全部内容。如你不同意本声明的任何条款,请立即停止使用并卸载本软件。",
|
||||
"disclaimerAndroidOnly": "仅 Android",
|
||||
"disclaimerGooglePrivacy": "Google 隐私政策",
|
||||
"disclaimerFirebasePrivacy": "Firebase 隐私和安全说明",
|
||||
"agree": "同意",
|
||||
"hotkeyManagement": "快捷键管理",
|
||||
"hotkeyManagementDesc": "使用键盘控制应用程序",
|
||||
"pressKeyboard": "请按下按键",
|
||||
"inputCorrectHotkey": "请输入正确的快捷键",
|
||||
"hotkeyConflict": "快捷键冲突",
|
||||
"hotkeyDesc": "全局快捷键在窗口隐藏时依然生效,点击操作即可录制组合键。",
|
||||
"hotkeyNotSet": "未设置",
|
||||
"pressKeyboard": "请按下组合键",
|
||||
"hotkeyNeedsModifier": "至少包含 {modifiers} 中的一个",
|
||||
"hotkeyConflictWith": "已被“{action}”使用,保存后将移到这里",
|
||||
"hotkeyUnavailable": "未能注册,可能已被其他应用占用",
|
||||
"remove": "移除",
|
||||
"noHotKey": "暂无快捷键",
|
||||
"noNetwork": "无网络",
|
||||
"ipv6InboundDesc": "允许IPv6入站",
|
||||
"exportLogs": "导出日志",
|
||||
"exportSuccess": "导出成功",
|
||||
"iconStyle": "图标样式",
|
||||
"onlyIcon": "仅图标",
|
||||
"iconStyleFilled": "色块",
|
||||
"iconStylePlain": "纯图标",
|
||||
"iconStyleHidden": "隐藏",
|
||||
"filter": "筛选",
|
||||
"hideTimeoutProxies": "隐藏超时节点",
|
||||
"hideTimeoutProxiesDesc": "不显示上次延迟测试超时的节点",
|
||||
"stackMode": "栈模式",
|
||||
"network": "网络",
|
||||
"networkDesc": "修改网络相关设置",
|
||||
"bypassDomain": "排除域名",
|
||||
"bypassDomainDesc": "仅在系统代理启用时生效",
|
||||
"resetTip": "确定要重置吗?",
|
||||
"resetTip": "确定要重置吗?",
|
||||
"icon": "图片",
|
||||
"noData": "暂无数据",
|
||||
"fontFamily": "字体",
|
||||
@@ -284,7 +342,6 @@
|
||||
"routeModeBypassPrivate": "绕过私有路由地址",
|
||||
"routeModeConfig": "使用配置",
|
||||
"routeAddress": "路由地址",
|
||||
"routeAddressDesc": "配置监听路由地址",
|
||||
"interfaceNameMode": "出站网卡",
|
||||
"interfaceNameModeClear": "清空",
|
||||
"interfaceNameModeFollow": "跟随配置",
|
||||
@@ -293,9 +350,23 @@
|
||||
"interfaceNameDesc": "出站连接使用的网卡名称",
|
||||
"copyEnvVar": "复制环境变量",
|
||||
"memoryInfo": "内存信息",
|
||||
"total": "总计",
|
||||
"releaseMemory": "释放内存",
|
||||
"memoryCoreHeapInuse": "堆内存使用中",
|
||||
"memoryCoreHeapIdle": "堆内存空闲",
|
||||
"memoryCoreStack": "协程栈",
|
||||
"memoryCoreRuntime": "运行时开销",
|
||||
"memoryCoreNotRunning": "内核未运行",
|
||||
"memoryAppResident": "常驻内存",
|
||||
"memoryAppShared": "应用及共享",
|
||||
"memoryEstimateDesc": "基于进程常驻内存估算,可能与系统显示的数值不同。",
|
||||
"memoryEstimateSharedDesc": "内核与应用运行在同一进程,内核部分按运行时统计估算,其余计入应用及共享内存。",
|
||||
"memoryReleased": "内存已释放",
|
||||
"memoryReleasedSize": "已释放 {size}",
|
||||
"releaseMemoryFailed": "释放内存失败",
|
||||
"cancel": "取消",
|
||||
"fileIsUpdate": "文件有修改,是否保存修改",
|
||||
"profileHasUpdate": "配置文件已经修改,是否关闭自动更新 ",
|
||||
"profileHasUpdate": "配置文件已经修改,是否关闭自动更新?",
|
||||
"hasCacheChange": "是否缓存修改",
|
||||
"copySuccess": "复制成功",
|
||||
"copyLink": "复制链接",
|
||||
@@ -305,27 +376,27 @@
|
||||
"listen": "监听",
|
||||
"undo": "撤销",
|
||||
"redo": "重做",
|
||||
"lineWrap": "自动换行",
|
||||
"none": "无",
|
||||
"basicConfig": "基础配置",
|
||||
"basicConfigDesc": "全局修改基础配置",
|
||||
"advancedConfig": "进阶配置",
|
||||
"advancedConfigDesc": "提供多样化配置",
|
||||
"advancedConfigDesc": "网络、DNS、附加规则与脚本",
|
||||
"selectedCountTitle": "已选择 {count} 项",
|
||||
"addRule": "添加规则",
|
||||
"ruleName": "规则名称",
|
||||
"content": "内容",
|
||||
"subRule": "子规则",
|
||||
"ruleTarget": "规则目标",
|
||||
"matchTargetTitle": "匹配目标",
|
||||
"matchTarget": "MATCH-TARGET",
|
||||
"followProfile": "跟随配置",
|
||||
"matchTargetDesc": "目标为 MATCH-TARGET 的规则路由到这里,默认取本配置末尾 MATCH 规则的目标。",
|
||||
"selectMatchTarget": "选择 MATCH-TARGET",
|
||||
"sourceIp": "源IP",
|
||||
"noResolve": "不解析IP",
|
||||
"saveChanges": "是否保存更改?",
|
||||
"findProcessModeDesc": "开启后会有一定性能损耗",
|
||||
"tabAnimationDesc": "仅在移动视图中有效",
|
||||
"navigationBarStyle": "底栏",
|
||||
"floating": "悬浮",
|
||||
"docked": "固定",
|
||||
"slide": "滑动",
|
||||
"fade": "淡入",
|
||||
"pureBlack": "纯黑",
|
||||
"colorSchemes": "配色方案",
|
||||
"palette": "调色板",
|
||||
"tonalSpotScheme": "调性点缀",
|
||||
@@ -348,6 +419,8 @@
|
||||
"launchInterruptedTip": "应用上次在启动过程中意外退出。已跳过本次自动配置,你可以手动启动重试。",
|
||||
"clearData": "清除数据",
|
||||
"textScale": "文本缩放",
|
||||
"textScalePreview": "应用内的文字将以这个大小显示",
|
||||
"followSystem": "跟随系统",
|
||||
"internet": "互联网",
|
||||
"systemApp": "系统应用",
|
||||
"noNetworkApp": "无网络应用",
|
||||
@@ -361,7 +434,13 @@
|
||||
"interval": "间隔",
|
||||
"existsTip": "{label}当前已存在",
|
||||
"maxLengthTip": "{label}最多{max}个字符",
|
||||
"multipleValuesTip": "多个值使用逗号分隔",
|
||||
"singleAdd": "单条添加",
|
||||
"batchAdd": "批量添加",
|
||||
"batchListInputTip": "每行一项,也可用逗号分隔",
|
||||
"batchMapInputTip": "每行一条,键和值之间用空格分隔",
|
||||
"batchPreviewTip": "将添加 {count} 项,跳过 {skipped} 项已存在",
|
||||
"lineIssueTip": "第 {line} 行:{message}",
|
||||
"singleValueTip": "{label}只能是一项",
|
||||
"deleteTip": "确定删除当前{label}吗?",
|
||||
"deleteMultipTip": "确定删除选中的{label}吗?",
|
||||
"nullTip": "暂无{label}",
|
||||
@@ -378,6 +457,7 @@
|
||||
"import": "导入",
|
||||
"importFile": "通过文件导入",
|
||||
"importUrl": "通过URL导入",
|
||||
"startFromScratch": "从零开始",
|
||||
"autoSetSystemDns": "自动设置系统DNS",
|
||||
"details": "{label}详情",
|
||||
"creationTime": "创建时间",
|
||||
@@ -396,7 +476,7 @@
|
||||
"request": "请求",
|
||||
"connected": "已连接",
|
||||
"disconnected": "已断开",
|
||||
"connecting": "连接中...",
|
||||
"connecting": "连接中…",
|
||||
"restartCoreTip": "您确定要重启核心吗?",
|
||||
"forceRestartCoreTip": "您确定要强制重启核心吗?",
|
||||
"dnsHijacking": "DNS劫持",
|
||||
@@ -406,20 +486,25 @@
|
||||
"crashlytics": "崩溃分析",
|
||||
"crashlyticsTip": "开启后,应用崩溃时自动上传不包含敏感信息的崩溃日志",
|
||||
"appendSystemDns": "追加系统DNS",
|
||||
"appendSystemDnsTip": "强制为配置附加系统DNS",
|
||||
"editRule": "编辑规则",
|
||||
"overrideMode": "覆写模式",
|
||||
"standardModeDesc": "标准模式,覆写基础配置,提供简单追加规则能力",
|
||||
"scriptModeDesc": "脚本模式,使用外部扩展脚本,提供一键覆写配置的能力",
|
||||
"addedRules": "附加规则",
|
||||
"controlGlobalAddedRules": "控制全局附加规则",
|
||||
"quickAdd": "快捷添加",
|
||||
"rulePresetBlockQuic": "屏蔽 QUIC",
|
||||
"rulePresetBlockStun": "屏蔽 STUN",
|
||||
"rulePresetBlockDot": "屏蔽 DNS over TLS",
|
||||
"rulePresetSystemServicesDirect": "Apple 与 Microsoft 直连",
|
||||
"rulePresetBittorrentDirect": "BT 下载直连",
|
||||
"rulePresetLanDirect": "局域网直连",
|
||||
"overrideScript": "覆写脚本",
|
||||
"goToConfigureScript": "前往配置脚本",
|
||||
"editGlobalRules": "编辑全局规则",
|
||||
"externalFetch": "外部获取",
|
||||
"confirmForceCrashCore": "确定要强制崩溃核心?",
|
||||
"confirmClearAllData": "确定要清除所有数据?",
|
||||
"loading": "加载中...",
|
||||
"loading": "加载中…",
|
||||
"yearsAgo": "{count} 年前",
|
||||
"monthsAgo": "{count} 个月前",
|
||||
"daysAgo": "{count} 天前",
|
||||
@@ -435,26 +520,33 @@
|
||||
"speedStatistics": "网速统计",
|
||||
"resetPageChangesTip": "当前页面存在更改,确定重置吗?",
|
||||
"overwriteTypeCustom": "自定义",
|
||||
"overwriteTypeCustomDesc": "自定义模式,支持完全自定义修改代理组以及规则",
|
||||
"overwriteTypeCustomDesc": "自定义模式,支持完全自定义修改代理、策略组以及规则",
|
||||
"unknownNetworkError": "未知网络错误",
|
||||
"restoreException": "恢复异常",
|
||||
"networkException": "网络异常,请检查连接后重试",
|
||||
"backupFromNewerVersion": "该备份来自更高版本的应用,请先更新应用再恢复",
|
||||
"networkTimeoutError": "请求超时,请检查网络或代理后重试",
|
||||
"networkHostLookupError": "无法解析服务器地址,请确认链接正确且 DNS 可用",
|
||||
"networkConnectionError": "无法连接到服务器,请检查网络连接或代理设置",
|
||||
"networkTlsError": "安全连接失败,服务器证书可能无效,或连接被拦截",
|
||||
"networkCancelledError": "请求已取消",
|
||||
"networkAccessDeniedError": "服务器拒绝访问(HTTP {code}),链接可能已过期或凭据有误",
|
||||
"networkNotFoundError": "该地址下没有内容(HTTP {code}),请确认链接是否正确",
|
||||
"networkRateLimitedError": "请求过于频繁(HTTP 429),请稍后再试",
|
||||
"networkServerError": "服务器出现问题(HTTP {code}),请稍后再试",
|
||||
"networkBadResponseError": "服务器拒绝了请求(HTTP {code})",
|
||||
"networkRequestFailed": "网络请求失败:{detail}",
|
||||
"invalidBackupFile": "无效备份文件",
|
||||
"pruneCache": "修剪缓存",
|
||||
"backupAndRestore": "备份与恢复",
|
||||
"backupAndRestoreDesc": "通过WebDAV或者文件同步数据",
|
||||
"restore": "恢复",
|
||||
"restoreSuccess": "恢复成功",
|
||||
"restoreFromWebDAVDesc": "通过WebDAV恢复数据",
|
||||
"restoreFromFileDesc": "通过文件恢复数据",
|
||||
"restoreOnlyConfig": "仅恢复配置文件",
|
||||
"restoreAllData": "恢复所有数据",
|
||||
"addProfile": "添加配置",
|
||||
"delayTest": "延迟测试",
|
||||
"proxyGroupEmpty": "策略组为空",
|
||||
"proxyGroupNameEmpty": "策略组名称不能为空",
|
||||
"proxyGroupNameDuplicate": "策略组名称重复",
|
||||
"confirmExitWindow": "确定要退出当前窗口吗?",
|
||||
"confirmExitWindow": "确定要退出当前窗口吗?",
|
||||
"dataChangedSave": "检测到数据有更改,是否保存",
|
||||
"selectProxyProviders": "选择代理集",
|
||||
"proxyFilter": "节点过滤器",
|
||||
@@ -464,6 +556,8 @@
|
||||
"excludeProxyFilter": "排除节点过滤器",
|
||||
"excludeType": "排除类型",
|
||||
"expectedStatus": "预期状态",
|
||||
"strategy": "策略",
|
||||
"tolerance": "容差",
|
||||
"selectProxies": "选择代理",
|
||||
"inputProxyGroupName": "输入策略组名称",
|
||||
"helperCorruptTip": "Helper 服务不可用,无法启用 TUN 模式,请重新安装 FlClash。",
|
||||
@@ -488,7 +582,7 @@
|
||||
"basicStrategy": "基础策略",
|
||||
"editProxy": "编辑代理",
|
||||
"includeAllProxyProviders": "包含所有代理集",
|
||||
"includeAllProxyProvidersTip": "开启后将覆盖引入的代理集",
|
||||
"includeAllProxyProvidersTip": "开启后包含本配置的全部代理集:订阅自带的,以及任一策略组引用的配置和应用代理集",
|
||||
"addProxyProviders": "添加代理集",
|
||||
"includeAllProxies": "包含所有代理",
|
||||
"includeAllProxiesTip": "引入不包含策略组的所有代理,可在下方额外添加策略组",
|
||||
@@ -510,7 +604,7 @@
|
||||
"ruleActionDomainWildcardDesc": "通配符匹配,仅支持*和?通配符",
|
||||
"ruleActionGeositeDesc": "匹配 Geosite 内的域名",
|
||||
"ruleActionIpCidrDesc": "匹配 IP 地址范围",
|
||||
"ruleActionIpCidr6Desc": "匹配 IP 地址范围, IP-CIDR6 只是一个别名",
|
||||
"ruleActionIpCidr6Desc": "匹配 IP 地址范围,IP-CIDR6 只是一个别名",
|
||||
"ruleActionIpSuffixDesc": "匹配 IP 后缀范围",
|
||||
"ruleActionIpAsnDesc": "匹配 IP 所属 ASN",
|
||||
"ruleActionGeoipDesc": "匹配 IP 所属国家代码",
|
||||
@@ -533,12 +627,12 @@
|
||||
"ruleActionProcessNameWildcardDesc": "使用进程名称通配符匹配,仅支持*和?通配符",
|
||||
"ruleActionUidDesc": "匹配 Linux USER ID",
|
||||
"ruleActionNetworkDesc": "匹配TCP或者UDP",
|
||||
"ruleActionDscpDesc": "匹配DSCP标记 (仅限 tproxy udp 入站)",
|
||||
"ruleActionDscpDesc": "匹配DSCP标记(仅限 tproxy udp 入站)",
|
||||
"ruleActionRuleSetDesc": "引用规则集合,需配置rule-providers",
|
||||
"ruleActionAndDesc": "逻辑规则 AND",
|
||||
"ruleActionOrDesc": "逻辑规则 OR",
|
||||
"ruleActionNotDesc": "逻辑规则 NOT",
|
||||
"ruleActionSubRuleDesc": "匹配至子规则,需要注意括号的使用",
|
||||
"ruleActionSubRuleDesc": "匹配至子规则,需要注意括号的使用",
|
||||
"ruleActionMatchDesc": "匹配所有请求,无需条件",
|
||||
"subRuleEmpty": "子规则为空",
|
||||
"proxyProvidersNotEmpty": "代理集不能为空",
|
||||
@@ -550,11 +644,11 @@
|
||||
"invalidSubRule": "{subRule} 是一个无效的SUB_RULE",
|
||||
"invalidPolicy": "{target} 是一个无效的策略",
|
||||
"invalidProxyProvider": "{providerName} 是一个无效的代理集",
|
||||
"invalidRuleSet": "{ruleSet} 不是有效的规则集",
|
||||
"invalidNetworkContent": "仅支持 tcp 或 udp",
|
||||
"invalidRangeContent": "请输入数字或范围,如 80 或 8000-9000,多个用 / 分隔",
|
||||
"invalidDscpContent": "DSCP 标记不能超过 63",
|
||||
"invalidProxy": "{proxyName} 是一个无效的代理",
|
||||
"proxyGroupDetectedAbnormal": "检测到当前策略组异常",
|
||||
"proxyProviderDetectedAbnormal": "检测到选中的代理集存在异常",
|
||||
"proxyDetectedAbnormal": "检测到选中的代理存在异常",
|
||||
"createProfile": "创建配置",
|
||||
"locationPermissionRequired": "需要位置权限",
|
||||
"locationPermissionGuide": "1. 打开 系统设置 > 隐私与安全性\n2. 选择 定位服务\n3. 在右侧列表中找到并勾选 {appName}\n\n完成设置后,返回应用即可正常使用。感谢您的配合。",
|
||||
"prerequisites": "前置条件",
|
||||
@@ -573,7 +667,7 @@
|
||||
"editSsid": "编辑SSID",
|
||||
"authorized": "已授权",
|
||||
"tapToAuthorize": "点击授权",
|
||||
"suspended": "挂起中...",
|
||||
"suspended": "挂起中…",
|
||||
"geoOptions": "Geo 选项",
|
||||
"geoAutoUpdate": "自动更新",
|
||||
"geoAutoUpdateInterval": "自动更新间隔",
|
||||
@@ -584,7 +678,6 @@
|
||||
"geoSkipped": "{name} 已是最新版本",
|
||||
"geoUpdated": "{name} 已更新",
|
||||
"secondsCount": "{count} 秒",
|
||||
"entriesCount": "{count} 个条目",
|
||||
"proxiesCount": "{count} 个代理",
|
||||
"rulesCount": "{count} 条规则",
|
||||
"changelogBreaking": "重大变更",
|
||||
@@ -600,7 +693,6 @@
|
||||
"pinWindow": "窗口置顶",
|
||||
"unpinWindow": "取消置顶",
|
||||
"exitFullScreen": "退出全屏",
|
||||
"toggleLabel": "切换标签",
|
||||
"torch": "手电筒",
|
||||
"pickFromAlbum": "从相册选择",
|
||||
"blockConnection": "阻止连接",
|
||||
@@ -611,11 +703,79 @@
|
||||
"previousMatch": "上一个匹配",
|
||||
"nextMatch": "下一个匹配",
|
||||
"clearSearch": "清除搜索",
|
||||
"noSearchResults": "没有匹配的结果",
|
||||
"addWidget": "添加组件",
|
||||
"showPassword": "显示密码",
|
||||
"hidePassword": "隐藏密码",
|
||||
"authorize": "授权",
|
||||
"installedAppsPermissionRequired": "需要读取应用列表权限",
|
||||
"installedAppsPermissionDesc": "当前系统在授权前不会提供已安装的应用列表,授权后即可配置分应用代理。",
|
||||
"installedAppsPermissionDeniedMessage": "读取应用列表权限已被拒绝,无法获取已安装的应用。请前往系统设置手动开启。"
|
||||
"installedAppsPermissionDeniedMessage": "读取应用列表权限已被拒绝,无法获取已安装的应用。请前往系统设置手动开启。",
|
||||
"outboundIp": "出站 IP",
|
||||
"ipAddress": "IP 地址",
|
||||
"ipQualityLevel": "等级",
|
||||
"ipQualityGood": "优",
|
||||
"ipQualityNormal": "普通",
|
||||
"ipQualityRisky": "风险",
|
||||
"ipType": "类型",
|
||||
"ipTypeResidential": "住宅",
|
||||
"ipTypeMobile": "移动网络",
|
||||
"ipTypeBusiness": "商业",
|
||||
"ipTypeHosting": "机房",
|
||||
"ipFlags": "命中标记",
|
||||
"ipFlagProxy": "代理",
|
||||
"ipFlagVpn": "VPN",
|
||||
"ipFlagTor": "Tor",
|
||||
"ipFlagAbuser": "滥用记录",
|
||||
"ipOrganization": "组织",
|
||||
"ipAsn": "ASN",
|
||||
"ipQualitySource": "采用来源",
|
||||
"ipQualitySources": "各来源",
|
||||
"ipQualityFailed": "类型查询失败",
|
||||
"ipQualityRetry": "重新查询",
|
||||
"ipSourceNoType": "无法判定类型",
|
||||
"ipSourceRateLimited": "限流",
|
||||
"ipSourceIpMismatch": "出站 IP 不一致",
|
||||
"hideIp": "隐藏 IP",
|
||||
"editorUnavailable": "编辑器不可用",
|
||||
"replace": "替换",
|
||||
"replaceAll": "全部替换",
|
||||
"cameraPermissionRequired": "需要相机权限",
|
||||
"cameraPermissionDesc": "请在系统设置中允许访问相机以扫描二维码,或从相册选择二维码图片。",
|
||||
"cameraUnavailable": "相机不可用",
|
||||
"retry": "重试",
|
||||
"invalidProfileQrcode": "该二维码不包含配置文件链接",
|
||||
"dnsQueries": "DNS查询",
|
||||
"recordType": "记录类型",
|
||||
"answers": "应答",
|
||||
"responseCode": "响应码",
|
||||
"error": "错误",
|
||||
"initiator": "发起方",
|
||||
"cache": "缓存",
|
||||
"proxyNode": "代理节点",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"nonTextProviderFile": "该外部资源不是文本文件",
|
||||
"addCustomProxy": "添加代理",
|
||||
"proxyDefinition": "完整配置",
|
||||
"proxyDefinitionNotMap": "配置必须是包含 name 和 type 的 YAML 映射",
|
||||
"customProxiesEmpty": "未添加自定义代理,沿用配置文件自身的代理",
|
||||
"overwriteIssueEmptyName": "名称为空",
|
||||
"overwriteIssueReservedName": "{name} 是内置策略名,不能在此使用",
|
||||
"overwriteIssueDuplicateName": "名称 {name} 已被其他代理或策略组使用",
|
||||
"overwriteIssueCoreRejected": "内核无法解析该代理:{message}",
|
||||
"overwriteIssueMissingProxies": "以下代理或策略不存在:{names}",
|
||||
"overwriteIssueMissingProviders": "以下代理集不存在:{names}",
|
||||
"overwriteIssueNoProxySource": "未选择任何代理或代理集,内核会拒绝该策略组",
|
||||
"overwriteIssueGroupLoop": "策略组之间存在循环引用:{path}",
|
||||
"overwriteIssuesSummary": "{count} 项存在问题,应用覆写可能失败",
|
||||
"overwriteIssueSubscriptionGroupMissingProxies": "配置文件自身的策略组引用了自定义代理中已没有的代理:{names}",
|
||||
"providerInUse": "{label} 仍被 {profiles} 的自定义策略组或规则使用,请先在那里移除",
|
||||
"providerSourceSubscription": "订阅",
|
||||
"providerRenameShadowed": "{profiles} 的订阅里已有 {label},改名后这些配置会改用订阅里的那个,请换一个名称",
|
||||
"fontSize": "大小",
|
||||
"large": "大",
|
||||
"extraLarge": "超大",
|
||||
"switchProfile": "切换配置",
|
||||
"lastUpdated": "上次更新"
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="69" y="69" width="70" height="70" rx="22" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<rect x="161" y="69" width="70" height="70" rx="35" fill="#6750A4"/>
|
||||
<rect x="69" y="161" width="70" height="70" rx="35" fill="#6750A4" opacity="0.58"/>
|
||||
<path d="M174 161H218C225.18 161 231 166.82 231 174V218C231 225.18 225.18 231 218 231H174C166.82 231 161 225.18 161 218V174C161 166.82 166.82 161 174 161Z" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<path d="M90 104H118" stroke="#6750A4" stroke-width="8" stroke-linecap="round"/>
|
||||
<path d="M104 90V118" stroke="#6750A4" stroke-width="8" stroke-linecap="round"/>
|
||||
<path d="M181 196H211" stroke="#6750A4" stroke-width="8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 827 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M122 150H178" stroke="#6750A4" stroke-width="14" stroke-linecap="round"/>
|
||||
<circle cx="92" cy="150" r="34" fill="#FDF7FF" stroke="#6750A4" stroke-width="9"/>
|
||||
<circle cx="208" cy="150" r="34" fill="#6750A4"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 329 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M82 108C82 90.33 112.45 76 150 76C187.55 76 218 90.33 218 108V197C218 214.67 187.55 229 150 229C112.45 229 82 214.67 82 197V108Z" fill="#6750A4"/>
|
||||
<ellipse cx="150" cy="108" rx="52" ry="17" fill="#FDF7FF"/>
|
||||
<path d="M105 161C128 170 172 170 195 161" stroke="#FDF7FF" stroke-width="9" stroke-linecap="round" opacity="0.72"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 446 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="150" cy="150" r="78" fill="#FDF7FF" stroke="#6750A4" stroke-width="12"/>
|
||||
<path d="M150 105V154L183 177" stroke="#6750A4" stroke-width="13" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 311 B |
@@ -1,10 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="57" y="72" width="186" height="156" rx="28" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<path d="M57 100C57 84.54 69.54 72 85 72H215C230.46 72 243 84.54 243 100V112H57V100Z" fill="#E8DEF8"/>
|
||||
<circle cx="84" cy="92" r="6" fill="#6750A4"/>
|
||||
<circle cx="104" cy="92" r="6" fill="#6750A4" opacity="0.55"/>
|
||||
<path d="M88 150L108 166L88 182" stroke="#6750A4" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M126 183H169" stroke="#6750A4" stroke-width="10" stroke-linecap="round"/>
|
||||
<circle cx="215" cy="204" r="34" fill="#6750A4" stroke="#FDF7FF" stroke-width="6"/>
|
||||
<path d="M215 186V204L226 214" stroke="#FDF7FF" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 838 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="91" y="126" width="118" height="92" rx="28" fill="#6750A4"/>
|
||||
<path d="M116 126V111C116 92 131 77 150 77C169 77 184 92 184 111V126" stroke="#6750A4" stroke-width="15" stroke-linecap="round"/>
|
||||
<circle cx="150" cy="169" r="10" fill="#FDF7FF"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 363 B |
@@ -1,14 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M82 58H182L218 94V234C218 244 210 252 200 252H82C72 252 64 244 64 234V76C64 66 72 58 82 58Z" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M182 58V94H218" fill="#E8DEF8"/>
|
||||
<path d="M182 58V94H218" stroke="#C4C7C5" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M94 124H188" stroke="#6750A4" stroke-width="8" stroke-linecap="round" opacity="0.55"/>
|
||||
<circle cx="126" cy="124" r="12" fill="#6750A4"/>
|
||||
<path d="M94 164H188" stroke="#6750A4" stroke-width="8" stroke-linecap="round" opacity="0.55"/>
|
||||
<circle cx="166" cy="164" r="12" fill="#6750A4"/>
|
||||
<path d="M94 204H188" stroke="#6750A4" stroke-width="8" stroke-linecap="round" opacity="0.55"/>
|
||||
<circle cx="142" cy="204" r="12" fill="#6750A4"/>
|
||||
<circle cx="213" cy="218" r="33" fill="#6750A4" stroke="#FDF7FF" stroke-width="6"/>
|
||||
<path d="M193 220C204 207 222 207 233 220" stroke="#FDF7FF" stroke-width="7" stroke-linecap="round"/>
|
||||
<circle cx="213" cy="204" r="8" fill="#FDF7FF"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,6 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M150 205V153M150 153L96 99M150 153L204 99" stroke="#6750A4" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="96" cy="99" r="27" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<circle cx="204" cy="99" r="27" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<circle cx="150" cy="205" r="30" fill="#6750A4"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 466 B |
@@ -1,6 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="96" y="72" width="145" height="156" rx="28" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<circle cx="120" cy="100" r="6" fill="#6750A4"/>
|
||||
<circle cx="140" cy="100" r="6" fill="#6750A4" opacity="0.55"/>
|
||||
<path d="M58 158H190M171 139L192 158L171 177" stroke="#6750A4" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 462 B |
@@ -1,7 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M63 69H237L174 148V221L126 246V148L63 69Z" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M95 101H205" stroke="#6750A4" stroke-width="10" stroke-linecap="round"/>
|
||||
<path d="M113 127H187" stroke="#6750A4" stroke-width="10" stroke-linecap="round" opacity="0.55"/>
|
||||
<circle cx="215" cy="205" r="34" fill="#6750A4" stroke="#FDF7FF" stroke-width="6"/>
|
||||
<path d="M199 205L210 216L231 193" stroke="#FDF7FF" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 626 B |
@@ -1,9 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="54" y="65" width="192" height="170" rx="30" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<path d="M54 104H246" stroke="#E8DEF8" stroke-width="18"/>
|
||||
<circle cx="82" cy="84" r="6" fill="#6750A4"/>
|
||||
<circle cx="103" cy="84" r="6" fill="#6750A4" opacity="0.55"/>
|
||||
<path d="M112 137L82 166L112 195" stroke="#6750A4" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M188 137L218 166L188 195" stroke="#6750A4" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M169 126L133 206" stroke="#6750A4" stroke-width="12" stroke-linecap="round" opacity="0.62"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 733 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M89 133C123 102 177 102 211 133" stroke="#6750A4" stroke-width="18" stroke-linecap="round"/>
|
||||
<path d="M119 172C136 157 164 157 181 172" stroke="#6750A4" stroke-width="18" stroke-linecap="round" opacity="0.62"/>
|
||||
<circle cx="150" cy="210" r="12" fill="#6750A4"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 383 B |
@@ -0,0 +1,13 @@
|
||||
YouTube, OpenAI, Google, GitHub, Gemini, Netflix, Prime Video, Spotify, TikTok
|
||||
and bilibili icons: Simple Icons 13.0.0.
|
||||
Source: https://github.com/simple-icons/simple-icons/tree/13.0.0/icons
|
||||
License: CC0 1.0 Universal.
|
||||
License text: https://github.com/simple-icons/simple-icons/blob/13.0.0/LICENSE.md
|
||||
|
||||
The Claude sun symbol is an original vector illustration for FlClash.
|
||||
|
||||
The Disney+ entry uses a neutral play-and-plus glyph drawn for FlClash, because
|
||||
Simple Icons 13.0.0 carries no Disney+ icon; it is a generic streaming mark, not
|
||||
a reproduction of any Disney brand asset.
|
||||
|
||||
Brand names and marks belong to their respective owners.
|
||||
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bilibili</title><path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m12 2-.2 6.8M17 3.4l-3 6.2M21 7l-6.3 3.4M22 12l-7 .2M20.6 17l-6.2-3M17 21l-3.4-6.3M12 22l-.2-7M7 20.6l3-6.2M3 17l6.3-3.4M2 12l7-.2M3.4 7l6.2 3M7 3l3.4 6.3"/></g><path d="m12 8 3 2 1 3-3 3-3-1-2-3 1-3z"/></svg>
|
||||
|
After Width: | Height: | Size: 356 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Disney+</title><path d="M3.6 4.2h10.8a2.4 2.4 0 0 1 2.4 2.4v10.8a2.4 2.4 0 0 1-2.4 2.4H3.6a2.4 2.4 0 0 1-2.4-2.4V6.6a2.4 2.4 0 0 1 2.4-2.4Zm3 4.2v7.2l6-3.6-6-3.6ZM19.2 8.4h1.8v2.7h2.7v1.8h-2.7v2.7h-1.8v-2.7h-2.7v-1.8h2.7V8.4Z"/></svg>
|
||||
|
After Width: | Height: | Size: 302 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google Gemini</title><path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/></svg>
|
||||
|
After Width: | Height: | Size: 401 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
|
||||
|
After Width: | Height: | Size: 822 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google</title><path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"/></svg>
|
||||
|
After Width: | Height: | Size: 457 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Netflix</title><path d="M5.398 0v.006c3.028 8.556 5.37 15.175 8.348 23.596 2.344.058 4.85.398 4.854.398-2.8-7.924-5.923-16.747-8.487-24zm8.489 0v9.63L18.6 22.951c-.043-7.86-.004-15.913.002-22.95zM5.398 1.05V24c1.873-.225 2.81-.312 4.715-.398v-9.22z"/></svg>
|
||||
|
After Width: | Height: | Size: 335 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Spotify</title><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>
|
||||
|
After Width: | Height: | Size: 697 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>TikTok</title><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>
|
||||
|
After Width: | Height: | Size: 707 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>YouTube</title><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
|
||||
|
After Width: | Height: | Size: 459 B |
|
After Width: | Height: | Size: 570 B |
|
After Width: | Height: | Size: 698 B |
|
After Width: | Height: | Size: 851 B |
|
After Width: | Height: | Size: 1012 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 323 B |
|
After Width: | Height: | Size: 400 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 618 B |
|
After Width: | Height: | Size: 8.6 KiB |
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Logo glyph: two bars and a dot on a 30° lattice, pitch 61.76, radius 23.16.
|
||||
The Android notification drawables ic.xml and ic_service.xml carry the same geometry as fill paths. -->
|
||||
<svg width="240" height="240" viewBox="0 0 240 240" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>glyph</title>
|
||||
<g stroke-width="46.32" stroke-linecap="round">
|
||||
<line x1="59.68" y1="100.95" x2="180.02" y2="31.47" stroke="#6666FB"/>
|
||||
<line x1="90.56" y1="154.43" x2="150.74" y2="119.69" stroke="#336AB6"/>
|
||||
</g>
|
||||
<circle cx="121.44" cy="207.92" r="23.16" fill="#5CA8E9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 617 B |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Glyph: Material Symbols "bug_report" (Rounded, filled), Apache License 2.0 -->
|
||||
<svg width="108" height="108" viewBox="100 -854 760 760" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>status_4</title>
|
||||
<defs>
|
||||
<linearGradient id="bug-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#F59E0B"/>
|
||||
<stop offset="100%" stop-color="#EA580C"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#bug-grad)" d="M480-120q-65.37 0-121.18-31Q303-182 276-240h-86q-12.75 0-21.37-8.68-8.63-8.67-8.63-21.5 0-12.82 8.63-21.32 8.62-8.5 21.37-8.5h62q-7-26-7-52.67V-406h-56q-12.75 0-21.37-8.68-8.63-8.67-8.63-21.5 0-12.82 8.63-21.32 8.62-8.5 21.37-8.5h56q0-29 .5-57.5T254-580h-64q-12.75 0-21.37-8.68-8.63-8.67-8.63-21.5 0-12.82 8.63-21.32 8.62-8.5 21.37-8.5h90q14-28 37-49t51-35l-57-56q-8-8-8-19.79 0-11.78 8.42-20.35 7.72-7.86 19.65-7.86t19.93 8l74 74q27.67-10 56.34-10Q510-756 538-746l74-74q8-8 19.79-8 11.78 0 20.35 8.42 7.86 7.72 7.86 19.65T652-780l-56 56q28 14 49.4 35.67Q666.8-666.67 683-640h88q12.75 0 21.38 8.68 8.62 8.67 8.62 21.5 0 12.82-8.62 21.32-8.63 8.5-21.38 8.5h-65q9 28 8.5 56.67-.5 28.66-.5 57.33h57q12.75 0 21.38 8.68 8.62 8.67 8.62 21.5 0 12.82-8.62 21.32-8.63 8.5-21.38 8.5h-57q0 27 .5 53.5T708-300h63q12.75 0 21.38 8.68 8.62 8.67 8.62 21.5 0 12.82-8.62 21.32-8.63 8.5-21.38 8.5h-86q-26 59-82.45 89.5Q546.11-120 480-120Zm-50-200h100q12.75 0 21.38-8.68 8.62-8.67 8.62-21.5 0-12.82-8.62-21.32-8.63-8.5-21.38-8.5H430q-12.75 0-21.37 8.68-8.63 8.67-8.63 21.5 0 12.82 8.63 21.32 8.62 8.5 21.37 8.5Zm0-173h100q12.75 0 21.38-8.68 8.62-8.67 8.62-21.5 0-12.82-8.62-21.32-8.63-8.5-21.38-8.5H430q-12.75 0-21.37 8.68-8.63 8.67-8.63 21.5 0 12.82 8.63 21.32 8.62 8.5 21.37 8.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -55,11 +55,10 @@ var (
|
||||
// configMu -> selectMu.
|
||||
selectMu sync.Mutex
|
||||
|
||||
isInit atomic.Bool
|
||||
isRunning atomic.Bool
|
||||
isSuspended atomic.Bool
|
||||
sdkVersion atomic.Int32
|
||||
testURL atomic.Pointer[string]
|
||||
isInit atomic.Bool
|
||||
isRunning atomic.Bool
|
||||
sdkVersion atomic.Int32
|
||||
testURL atomic.Pointer[string]
|
||||
|
||||
delayTestSlots = make(chan struct{}, delayTestConcurrency)
|
||||
|
||||
@@ -290,7 +289,10 @@ func updateConfig(params *UpdateParams) error {
|
||||
general.UnifiedDelay = *params.UnifiedDelay
|
||||
adapter.UnifiedDelay.Store(general.UnifiedDelay)
|
||||
}
|
||||
// IPv6 belongs with mode: turning it on moves a direct dial to another exit address.
|
||||
routeChanged := false
|
||||
if params.Mode != nil {
|
||||
routeChanged = routeChanged || general.Mode != *params.Mode
|
||||
general.Mode = *params.Mode
|
||||
tunnel.SetMode(general.Mode)
|
||||
}
|
||||
@@ -299,6 +301,7 @@ func updateConfig(params *UpdateParams) error {
|
||||
log.SetLevel(general.LogLevel)
|
||||
}
|
||||
if params.IPv6 != nil {
|
||||
routeChanged = routeChanged || general.IPv6 != *params.IPv6
|
||||
general.IPv6 = *params.IPv6
|
||||
resolver.DisableIPv6 = !general.IPv6
|
||||
}
|
||||
@@ -316,6 +319,9 @@ func updateConfig(params *UpdateParams) error {
|
||||
|
||||
updateListeners(currentConfig)
|
||||
syncGeoUpdater(params.GeoAutoUpdate, params.GeoUpdateInterval)
|
||||
if routeChanged {
|
||||
bumpRouteEpoch()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -365,12 +371,24 @@ func reconcileGeoUpdater() {
|
||||
stopGeoUpdater()
|
||||
}
|
||||
|
||||
// mihomo writes the NTP time with settimeofday, which Android's app seccomp
|
||||
// policy answers by killing the process rather than with EPERM.
|
||||
var systemTimeWritable = runtime.GOOS != "android"
|
||||
|
||||
func loadConfig(path string) (*config.Config, error) {
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return executor.ParseWithBytes(buf)
|
||||
cfg, err := executor.ParseWithBytes(buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.NTP.WriteToSystem && !systemTimeWritable {
|
||||
log.Warnln("ntp write-to-system ignored: Android does not let apps set the system time")
|
||||
cfg.NTP.WriteToSystem = false
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func applyConfig(params *SetupParams) error {
|
||||
@@ -400,6 +418,7 @@ func applyConfig(params *SetupParams) error {
|
||||
currentConfig = cfg
|
||||
hub.ApplyConfig(cfg)
|
||||
patchSelectGroup(params.SelectedMap)
|
||||
bumpRouteEpoch()
|
||||
updateListeners(cfg)
|
||||
reconcileGeoUpdater()
|
||||
return err
|
||||
|
||||
@@ -598,6 +598,33 @@ func TestLoadConfigReportsMalformedYaml(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigKeepsTheSystemTimeWriteOnlyWhereItIsAllowed(t *testing.T) {
|
||||
previous := systemTimeWritable
|
||||
t.Cleanup(func() { systemTimeWritable = previous })
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
profile := "ntp:\n enable: true\n write-to-system: true\n"
|
||||
if err := os.WriteFile(path, []byte(profile), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
for _, writable := range []bool{true, false} {
|
||||
systemTimeWritable = writable
|
||||
|
||||
cfg, err := loadConfig(path)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig = %v", err)
|
||||
}
|
||||
if cfg.NTP.WriteToSystem != writable {
|
||||
t.Errorf("writable %t: WriteToSystem = %t", writable, cfg.NTP.WriteToSystem)
|
||||
}
|
||||
if !cfg.NTP.Enable {
|
||||
t.Errorf("writable %t: NTP was disabled, want only the system write dropped", writable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func withSetupConfig(t *testing.T, apply func(*SetupParams) error) {
|
||||
t.Helper()
|
||||
previous := setupConfig
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/metacubex/http"
|
||||
|
||||
"github.com/metacubex/mihomo/adapter/provider"
|
||||
P "github.com/metacubex/mihomo/component/process"
|
||||
"github.com/metacubex/mihomo/constant"
|
||||
@@ -55,17 +57,63 @@ type ChangeProxyParams struct {
|
||||
ProxyName string `json:"proxy-name"`
|
||||
}
|
||||
|
||||
type ChangeProxyResult struct {
|
||||
Message string `json:"message"`
|
||||
Changed bool `json:"changed"`
|
||||
}
|
||||
|
||||
type RouteState struct {
|
||||
CoreEpoch uint64 `json:"core-epoch"`
|
||||
PicksVersion uint64 `json:"picks-version"`
|
||||
Picks map[string]string `json:"picks"`
|
||||
}
|
||||
|
||||
type TestDelayParams struct {
|
||||
ProxyName string `json:"proxy-name"`
|
||||
TestUrl string `json:"test-url"`
|
||||
Timeout int64 `json:"timeout"`
|
||||
}
|
||||
|
||||
type ProbeParams struct {
|
||||
Url string `json:"url"`
|
||||
ProxyName string `json:"proxy-name"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Timeout int64 `json:"timeout"`
|
||||
MaxBody int64 `json:"max-body"`
|
||||
}
|
||||
|
||||
type ProbeResult struct {
|
||||
StatusCode int `json:"status-code"`
|
||||
Delay int64 `json:"delay"`
|
||||
Body string `json:"body"`
|
||||
Url string `json:"url"`
|
||||
Chains []string `json:"chains"`
|
||||
Rule string `json:"rule"`
|
||||
RulePayload string `json:"rule-payload"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
|
||||
CoreEpoch uint64 `json:"core-epoch"`
|
||||
PicksVersion uint64 `json:"picks-version"`
|
||||
|
||||
// Unexported, so it never reaches Dart: only the in-core checks read
|
||||
// response headers, and ProbeResult is the shape the Dart side decodes.
|
||||
header http.Header
|
||||
}
|
||||
|
||||
type Traffic struct {
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
}
|
||||
|
||||
type MemoryStats struct {
|
||||
Rss uint64 `json:"rss"`
|
||||
HeapInuse uint64 `json:"heapInuse"`
|
||||
HeapIdle uint64 `json:"heapIdle"`
|
||||
StackInuse uint64 `json:"stackInuse"`
|
||||
RuntimeOther uint64 `json:"runtimeOther"`
|
||||
}
|
||||
|
||||
type ExternalProvider struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
@@ -77,8 +125,13 @@ type ExternalProvider struct {
|
||||
}
|
||||
|
||||
type ProxiesData struct {
|
||||
Proxies map[string]constant.Proxy `json:"proxies"`
|
||||
All []string `json:"all"`
|
||||
Proxies map[string]any `json:"proxies"`
|
||||
All []string `json:"all"`
|
||||
}
|
||||
|
||||
type nodeView struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -88,6 +141,7 @@ const (
|
||||
forceGcMethod CoreMethod = "forceGc"
|
||||
shutdownMethod CoreMethod = "shutdown"
|
||||
validateConfigMethod CoreMethod = "validateConfig"
|
||||
validateProxiesMethod CoreMethod = "validateProxies"
|
||||
updateConfigMethod CoreMethod = "updateConfig"
|
||||
getProxiesMethod CoreMethod = "getProxies"
|
||||
changeProxyMethod CoreMethod = "changeProxy"
|
||||
@@ -95,13 +149,17 @@ const (
|
||||
getTotalTrafficMethod CoreMethod = "getTotalTraffic"
|
||||
resetTrafficMethod CoreMethod = "resetTraffic"
|
||||
asyncTestDelayMethod CoreMethod = "asyncTestDelay"
|
||||
probeMethod CoreMethod = "probe"
|
||||
outboundIpMethod CoreMethod = "outboundIp"
|
||||
serviceCheckMethod CoreMethod = "serviceCheck"
|
||||
getConnectionsMethod CoreMethod = "getConnections"
|
||||
getConnectionCountMethod CoreMethod = "getConnectionCount"
|
||||
closeConnectionsMethod CoreMethod = "closeConnections"
|
||||
resetConnectionsMethod CoreMethod = "resetConnections"
|
||||
closeConnectionMethod CoreMethod = "closeConnection"
|
||||
getExternalProvidersMethod CoreMethod = "getExternalProviders"
|
||||
getExternalProviderMethod CoreMethod = "getExternalProvider"
|
||||
getMemoryMethod CoreMethod = "getMemory"
|
||||
getMemoryStatsMethod CoreMethod = "getMemoryStats"
|
||||
updateGeoDataMethod CoreMethod = "updateGeoData"
|
||||
updateExternalProviderMethod CoreMethod = "updateExternalProvider"
|
||||
sideLoadExternalProviderMethod CoreMethod = "sideLoadExternalProvider"
|
||||
@@ -113,7 +171,9 @@ const (
|
||||
crashMethod CoreMethod = "crash"
|
||||
setupConfigMethod CoreMethod = "setupConfig"
|
||||
getConfigMethod CoreMethod = "getConfig"
|
||||
dumpRuleSetMethod CoreMethod = "dumpRuleSet"
|
||||
clearEffectMethod CoreMethod = "clearEffect"
|
||||
watchRouteMethod CoreMethod = "watchRoute"
|
||||
)
|
||||
|
||||
type CoreMethod string
|
||||
@@ -135,8 +195,11 @@ const (
|
||||
LogMessage MessageType = "log"
|
||||
DelayMessage MessageType = "delay"
|
||||
RequestMessage MessageType = "request"
|
||||
DnsMessage MessageType = "dns"
|
||||
LoadedMessage MessageType = "loaded"
|
||||
GeoUpdateMessage MessageType = "geoUpdate"
|
||||
// Named after the Dart enum value, which is how CoreEvent.fromJson decodes it.
|
||||
RouteChangedMessage MessageType = "routeChanged"
|
||||
)
|
||||
|
||||
type GeoUpdateStatus struct {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/metacubex/mihomo/dns"
|
||||
D "github.com/miekg/dns"
|
||||
)
|
||||
|
||||
type DnsQuery struct {
|
||||
Domain string `json:"domain"`
|
||||
Type string `json:"type"`
|
||||
Initiator string `json:"initiator"`
|
||||
Upstream string `json:"upstream,omitempty"`
|
||||
Cached bool `json:"cached,omitempty"`
|
||||
Answers []string `json:"answers"`
|
||||
Rcode string `json:"rcode,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Delay int64 `json:"delay"`
|
||||
Time time.Time `json:"time"`
|
||||
}
|
||||
|
||||
func newDnsQuery(record dns.QueryRecord) DnsQuery {
|
||||
query := DnsQuery{
|
||||
Domain: strings.TrimSuffix(record.Question.Name, "."),
|
||||
Type: D.Type(record.Question.Qtype).String(),
|
||||
Initiator: record.Initiator,
|
||||
Upstream: record.Upstream,
|
||||
Cached: record.Cached,
|
||||
Answers: []string{},
|
||||
Delay: time.Since(record.Start).Milliseconds(),
|
||||
Time: record.Start,
|
||||
}
|
||||
if record.Err != nil {
|
||||
query.Error = record.Err.Error()
|
||||
}
|
||||
if record.Msg == nil {
|
||||
return query
|
||||
}
|
||||
query.Rcode = D.RcodeToString[record.Msg.Rcode]
|
||||
for _, rr := range record.Msg.Answer {
|
||||
query.Answers = append(query.Answers, dnsAnswerValue(rr))
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func dnsAnswerValue(rr D.RR) string {
|
||||
switch record := rr.(type) {
|
||||
case *D.A:
|
||||
return record.A.String()
|
||||
case *D.AAAA:
|
||||
return record.AAAA.String()
|
||||
case *D.CNAME:
|
||||
return strings.TrimSuffix(record.Target, ".")
|
||||
default:
|
||||
return strings.TrimSpace(strings.TrimPrefix(rr.String(), rr.Header().String()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/metacubex/mihomo/component/resolver"
|
||||
"github.com/metacubex/mihomo/dns"
|
||||
D "github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func dnsQuestion(name string, qtype uint16) D.Question {
|
||||
return D.Question{Name: D.Fqdn(name), Qtype: qtype, Qclass: D.ClassINET}
|
||||
}
|
||||
|
||||
func TestNewDnsQueryFlattensAnswers(t *testing.T) {
|
||||
request := new(D.Msg)
|
||||
request.SetQuestion("www.example.com.", D.TypeA)
|
||||
resp := new(D.Msg)
|
||||
resp.SetReply(request)
|
||||
header := func(rrtype uint16) D.RR_Header {
|
||||
return D.RR_Header{Name: "www.example.com.", Rrtype: rrtype, Class: D.ClassINET, Ttl: 60}
|
||||
}
|
||||
resp.Answer = []D.RR{
|
||||
&D.CNAME{Hdr: header(D.TypeCNAME), Target: "edge.example.net."},
|
||||
&D.A{Hdr: header(D.TypeA), A: net.ParseIP("93.184.216.34").To4()},
|
||||
&D.AAAA{Hdr: header(D.TypeAAAA), AAAA: net.ParseIP("2606:2800:220:1::248")},
|
||||
&D.TXT{Hdr: header(D.TypeTXT), Txt: []string{"v=spf1 -all"}},
|
||||
}
|
||||
start := time.Now()
|
||||
|
||||
query := newDnsQuery(dns.QueryRecord{
|
||||
Question: request.Question[0],
|
||||
Msg: resp,
|
||||
Initiator: resolver.InitiatorApp,
|
||||
Upstream: "tls://1.1.1.1:853",
|
||||
Cached: true,
|
||||
Start: start,
|
||||
})
|
||||
|
||||
if query.Domain != "www.example.com" || query.Type != "A" {
|
||||
t.Fatalf("query = %+v", query)
|
||||
}
|
||||
if query.Initiator != "app" || query.Upstream != "tls://1.1.1.1:853" || !query.Cached {
|
||||
t.Fatalf("initiator = %q, upstream = %q, cached = %v", query.Initiator, query.Upstream, query.Cached)
|
||||
}
|
||||
if query.Rcode != "NOERROR" || query.Error != "" {
|
||||
t.Fatalf("rcode = %q, error = %q", query.Rcode, query.Error)
|
||||
}
|
||||
want := []string{"edge.example.net", "93.184.216.34", "2606:2800:220:1::248", `"v=spf1 -all"`}
|
||||
if !reflect.DeepEqual(query.Answers, want) {
|
||||
t.Fatalf("answers = %q, want %q", query.Answers, want)
|
||||
}
|
||||
if !query.Time.Equal(start) {
|
||||
t.Fatalf("time = %v, want %v", query.Time, start)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDnsQueryKeepsTheFailure(t *testing.T) {
|
||||
query := newDnsQuery(dns.QueryRecord{
|
||||
Question: dnsQuestion("missing.test", D.TypeAAAA),
|
||||
Initiator: resolver.InitiatorDirect,
|
||||
Start: time.Now(),
|
||||
Err: errors.New("i/o timeout"),
|
||||
})
|
||||
|
||||
if query.Error != "i/o timeout" || query.Rcode != "" {
|
||||
t.Fatalf("error = %q, rcode = %q", query.Error, query.Rcode)
|
||||
}
|
||||
data, err := json.Marshal(query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if answers, ok := decoded["answers"].([]any); !ok || len(answers) != 0 {
|
||||
t.Fatalf("answers = %v, want an empty list", decoded["answers"])
|
||||
}
|
||||
for _, key := range []string{"upstream", "cached"} {
|
||||
if _, ok := decoded[key]; ok {
|
||||
t.Fatalf("%s = %v, want it omitted", key, decoded[key])
|
||||
}
|
||||
}
|
||||
if decoded["initiator"] != "direct" {
|
||||
t.Fatalf("initiator = %v, want direct", decoded["initiator"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDnsQueryNamesUnknownTypes(t *testing.T) {
|
||||
resp := new(D.Msg)
|
||||
resp.Rcode = D.RcodeNameError
|
||||
|
||||
query := newDnsQuery(dns.QueryRecord{
|
||||
Question: dnsQuestion("example.com", 65280),
|
||||
Msg: resp,
|
||||
Start: time.Now(),
|
||||
})
|
||||
|
||||
if query.Type != "TYPE65280" || query.Rcode != "NXDOMAIN" {
|
||||
t.Fatalf("type = %q, rcode = %q", query.Type, query.Rcode)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ replace github.com/metacubex/mihomo => ./Clash.Meta
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/metacubex/http v0.1.7
|
||||
github.com/metacubex/mihomo v0.0.0-00010101000000-000000000000
|
||||
github.com/miekg/dns v1.1.63
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -22,7 +24,8 @@ require (
|
||||
github.com/coreos/go-iptables v0.8.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/dunglas/httpsfv v1.0.2 // indirect
|
||||
github.com/enfein/mieru/v3 v3.35.0 // indirect
|
||||
github.com/easytier/easytier/easytier-go v0.0.0-20260910071355-3d0c9c3ca5e2 // indirect
|
||||
github.com/enfein/mieru/v3 v3.37.0 // indirect
|
||||
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 // indirect
|
||||
github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 // indirect
|
||||
github.com/ericlagergren/siv v0.0.0-20220507050439-0b757b3aa5f1 // indirect
|
||||
@@ -50,7 +53,7 @@ require (
|
||||
github.com/mdlayher/netlink v1.7.2 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 // indirect
|
||||
github.com/metacubex/amneziawg-go v0.0.0-20260816073447-736a78668832 // indirect
|
||||
github.com/metacubex/amneziawg-go v0.0.0-20260908071407-0c1c6f40ecd7 // indirect
|
||||
github.com/metacubex/ascon v0.1.0 // indirect
|
||||
github.com/metacubex/bart v0.29.0 // indirect
|
||||
github.com/metacubex/bbolt v0.0.0-20260706163408-d4ec34ad7c48 // indirect
|
||||
@@ -62,16 +65,15 @@ require (
|
||||
github.com/metacubex/edwards25519 v1.2.0 // indirect
|
||||
github.com/metacubex/fswatch v0.1.1 // indirect
|
||||
github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 // indirect
|
||||
github.com/metacubex/gvisor v0.0.0-20260810011720-3cc44cf9ac22 // indirect
|
||||
github.com/metacubex/gvisor v0.0.0-20260826100401-79317d808312 // indirect
|
||||
github.com/metacubex/hkdf v0.1.0 // indirect
|
||||
github.com/metacubex/hpke v0.1.0 // indirect
|
||||
github.com/metacubex/http v0.1.7 // indirect
|
||||
github.com/metacubex/jls-quic-go v0.0.0-20260727080412-732f2fc9a34d // indirect
|
||||
github.com/metacubex/jls-tls v0.0.0-20260723084315-67adc0e2f796 // indirect
|
||||
github.com/metacubex/jsonv2 v0.0.0-20260721082349-16b4998c8f89 // indirect
|
||||
github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 // indirect
|
||||
github.com/metacubex/mhurl v0.1.0 // indirect
|
||||
github.com/metacubex/mipstack v0.0.0-20260816065001-b7038299fe13 // indirect
|
||||
github.com/metacubex/mipstack v0.0.0-20260910230046-ba762df4c91d // indirect
|
||||
github.com/metacubex/mlkem v0.1.0 // indirect
|
||||
github.com/metacubex/nftables v0.0.0-20260426003805-208c2c1ba2cb // indirect
|
||||
github.com/metacubex/qpack v0.6.0 // indirect
|
||||
@@ -81,23 +83,23 @@ require (
|
||||
github.com/metacubex/sevenzip v1.6.4 // indirect
|
||||
github.com/metacubex/sing v0.5.7 // indirect
|
||||
github.com/metacubex/sing-mux v0.3.10 // indirect
|
||||
github.com/metacubex/sing-quic v0.0.0-20260726014900-38b0e9295f51 // indirect
|
||||
github.com/metacubex/sing-shadowsocks v0.2.12 // indirect
|
||||
github.com/metacubex/sing-shadowsocks2 v0.2.7 // indirect
|
||||
github.com/metacubex/sing-tun v0.4.22 // indirect
|
||||
github.com/metacubex/sing-quic v0.0.0-20260904234848-1c242664697a // indirect
|
||||
github.com/metacubex/sing-shadowsocks v0.2.13 // indirect
|
||||
github.com/metacubex/sing-shadowsocks2 v0.2.8 // indirect
|
||||
github.com/metacubex/sing-tun v0.4.24 // indirect
|
||||
github.com/metacubex/sing-vmess v0.2.5 // indirect
|
||||
github.com/metacubex/sing-wireguard v0.0.0-20260810013230-110eac03c3f0 // indirect
|
||||
github.com/metacubex/sing-wireguard v0.0.0-20260826105301-c3ae17d19f9e // indirect
|
||||
github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 // indirect
|
||||
github.com/metacubex/ssh v0.1.0 // indirect
|
||||
github.com/metacubex/tailscale v0.0.0-20260807072706-a4fb5feabcbb // indirect
|
||||
github.com/metacubex/tailscale v0.0.0-20260821153257-ff0ecd818181 // indirect
|
||||
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260725073821-e61ab99cede2 // indirect
|
||||
github.com/metacubex/tfo-go v0.0.0-20260623020846-376a77860b8c // indirect
|
||||
github.com/metacubex/tls v0.1.8 // indirect
|
||||
github.com/metacubex/utls v1.8.7 // indirect
|
||||
github.com/metacubex/wazero v0.0.0-20260628025728-9ae6bdcf2a7d // indirect
|
||||
github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f // indirect
|
||||
github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49 // indirect
|
||||
github.com/metacubex/zerotier-go v0.0.0-20260813124750-13fa6f45da5f // indirect
|
||||
github.com/miekg/dns v1.1.63 // indirect
|
||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||
github.com/mroth/weightedrand/v2 v2.1.0 // indirect
|
||||
github.com/oasisprotocol/deoxysii v0.0.0-20220228165953-2091330c22b7 // indirect
|
||||
@@ -127,6 +129,7 @@ require (
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
gitlab.com/go-extension/aes-ccm v0.0.0-20230221065045-e58665ef23c7 // indirect
|
||||
gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
@@ -140,5 +143,4 @@ require (
|
||||
golang.org/x/time v0.10.0 // indirect
|
||||
golang.org/x/tools v0.24.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -26,14 +26,15 @@ github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3C
|
||||
github.com/coreos/go-iptables v0.8.0 h1:MPc2P89IhuVpLI7ETL/2tx3XZ61VeICZjYqDEgNsPRc=
|
||||
github.com/coreos/go-iptables v0.8.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0=
|
||||
github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
|
||||
github.com/enfein/mieru/v3 v3.35.0 h1:AKQT6l9R6xcYaUocpfjqE/vWEvYMxpl+071bhfDXkMY=
|
||||
github.com/enfein/mieru/v3 v3.35.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
|
||||
github.com/easytier/easytier/easytier-go v0.0.0-20260910071355-3d0c9c3ca5e2 h1:2/9iRUcMR3QPrT2XFf/i7QV5YoSHPFjkgBM5tP8xbcc=
|
||||
github.com/easytier/easytier/easytier-go v0.0.0-20260910071355-3d0c9c3ca5e2/go.mod h1:fCEi5+K2yt+sPDONSWO1uNpOifrwPgFGvm3J0YrFRvk=
|
||||
github.com/enfein/mieru/v3 v3.37.0 h1:DKBDeBmlbpNKiOXtKXqxJo/BF+KvJKnbChn6fryeyHg=
|
||||
github.com/enfein/mieru/v3 v3.37.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
|
||||
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 h1:kXYqH/sL8dS/FdoFjr12ePjnLPorPo2FsnrHNuXSDyo=
|
||||
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358/go.mod h1:hkIFzoiIPZYxdFOOLyDho59b7SrDfo+w3h+yWdlg45I=
|
||||
github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 h1:8j2RH289RJplhA6WfdaPqzg1MjH2K8wX5e0uhAxrw2g=
|
||||
@@ -102,8 +103,8 @@ github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos
|
||||
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
|
||||
github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 h1:LqWr0vb9zDNuQS+jJd4fnRYk/SEI7KJ7TDe/L4WFK48=
|
||||
github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78/go.mod h1:BTBG/iVY7rg3qq5WdVCg0GFk58CSvCDSbjy8I7kEx/c=
|
||||
github.com/metacubex/amneziawg-go v0.0.0-20260816073447-736a78668832 h1:mAIVFc8Z9jziImzNl5bXUBVU2bTEmP2sXfosn/6YxPs=
|
||||
github.com/metacubex/amneziawg-go v0.0.0-20260816073447-736a78668832/go.mod h1:MsM/5czONyXMJ3PRr5DbQ4O/BxzAnJWOIcJdLzW6qHY=
|
||||
github.com/metacubex/amneziawg-go v0.0.0-20260908071407-0c1c6f40ecd7 h1:OJa/EzqUNScl8X4UCUfG5DDqRdEds89k2XwzBZrnqcE=
|
||||
github.com/metacubex/amneziawg-go v0.0.0-20260908071407-0c1c6f40ecd7/go.mod h1:MsM/5czONyXMJ3PRr5DbQ4O/BxzAnJWOIcJdLzW6qHY=
|
||||
github.com/metacubex/ascon v0.1.0 h1:6ZWxmXYszT1XXtwkf6nxfFhc/OTtQ9R3Vyj1jN32lGM=
|
||||
github.com/metacubex/ascon v0.1.0/go.mod h1:eV5oim4cVPPdEL8/EYaTZ0iIKARH9pnhAK/fcT5Kacc=
|
||||
github.com/metacubex/bart v0.29.0 h1:yVc/lLdDqn+7FShI0qUghStHl0/d3mn4EH/I5R5niKk=
|
||||
@@ -126,8 +127,8 @@ github.com/metacubex/fswatch v0.1.1 h1:jqU7C/v+g0qc2RUFgmAOPoVvfl2BXXUXEumn6oQux
|
||||
github.com/metacubex/fswatch v0.1.1/go.mod h1:czrTT7Zlbz7vWft8RQu9Qqh+JoX+Nnb+UabuyN1YsgI=
|
||||
github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 h1:cjd4biTvOzK9ubNCCkQ+ldc4YSH/rILn53l/xGBFHHI=
|
||||
github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759/go.mod h1:UHOv2xu+RIgLwpXca7TLrXleEd4oR3sPatW6IF8wU88=
|
||||
github.com/metacubex/gvisor v0.0.0-20260810011720-3cc44cf9ac22 h1:C4d4/BA8WuQmJnSpjy0UN4vJvRMjU3133rxIYQgbPPs=
|
||||
github.com/metacubex/gvisor v0.0.0-20260810011720-3cc44cf9ac22/go.mod h1:mBJW3UXUusd8ZYO5M6mig0uScIey9iIubgdIiQAk2ug=
|
||||
github.com/metacubex/gvisor v0.0.0-20260826100401-79317d808312 h1:GDSN5oSkugLGImDSM0W4UCovF5FLpTa6vL1g2f2+1do=
|
||||
github.com/metacubex/gvisor v0.0.0-20260826100401-79317d808312/go.mod h1:mBJW3UXUusd8ZYO5M6mig0uScIey9iIubgdIiQAk2ug=
|
||||
github.com/metacubex/hkdf v0.1.0 h1:fPA6VzXK8cU1foc/TOmGCDmSa7pZbxlnqhl3RNsthaA=
|
||||
github.com/metacubex/hkdf v0.1.0/go.mod h1:3seEfds3smgTAXqUGn+tgEJH3uXdsUjOiduG/2EtvZ4=
|
||||
github.com/metacubex/hpke v0.1.0 h1:gu2jUNhraehWi0P/z5HX2md3d7L1FhPQE6/Q0E9r9xQ=
|
||||
@@ -144,8 +145,8 @@ github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 h1:hJwCVlE3ojViC3
|
||||
github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604/go.mod h1:lpmN3m269b3V5jFCWtffqBLS4U3QQoIid9ugtO+OhVc=
|
||||
github.com/metacubex/mhurl v0.1.0 h1:ZdW4Zxe3j3uJ89gNytOazHu6kbHn5owutN/VfXOI8GE=
|
||||
github.com/metacubex/mhurl v0.1.0/go.mod h1:2qpQImCbXoUs6GwJrjuEXKelPyoimsIXr07eNKZdS00=
|
||||
github.com/metacubex/mipstack v0.0.0-20260816065001-b7038299fe13 h1:aOU4avd9u7uFivubzn+SjFmEDWAkGF+gvZ0ecEdeg64=
|
||||
github.com/metacubex/mipstack v0.0.0-20260816065001-b7038299fe13/go.mod h1:+bbwALZI0pbi2auSG5A3ptdpV2DZ2eLObziXo+P7oj0=
|
||||
github.com/metacubex/mipstack v0.0.0-20260910230046-ba762df4c91d h1:hc1OeKdo7YIcmTrzwlJLjNZ4EZDKRHi/Ntv7GdYs2YI=
|
||||
github.com/metacubex/mipstack v0.0.0-20260910230046-ba762df4c91d/go.mod h1:+bbwALZI0pbi2auSG5A3ptdpV2DZ2eLObziXo+P7oj0=
|
||||
github.com/metacubex/mlkem v0.1.0 h1:wFClitonSFcmipzzQvax75beLQU+D7JuC+VK1RzSL8I=
|
||||
github.com/metacubex/mlkem v0.1.0/go.mod h1:amhaXZVeYNShuy9BILcR7P0gbeo/QLZsnqCdL8U2PDQ=
|
||||
github.com/metacubex/nftables v0.0.0-20260426003805-208c2c1ba2cb h1:wk6mHYPURSUvWcUv72gNP79oiylFsscBSDPJ6ieV6Iw=
|
||||
@@ -164,24 +165,24 @@ github.com/metacubex/sing v0.5.7 h1:8OC+fhKFSv/l9ehEhJRaZZAOuthfZo68SteBVLe8QqM=
|
||||
github.com/metacubex/sing v0.5.7/go.mod h1:ypf0mjwlZm0sKdQSY+yQvmsbWa0hNPtkeqyRMGgoN+w=
|
||||
github.com/metacubex/sing-mux v0.3.10 h1:r5CuZ/KuwFsEcRRwpLvzLncW4fDzNfmSEcBEWcy/+94=
|
||||
github.com/metacubex/sing-mux v0.3.10/go.mod h1:8bT7ZKT3clRrJjYc/x5CRYibC1TX/bK73a3r3+2E+Fc=
|
||||
github.com/metacubex/sing-quic v0.0.0-20260726014900-38b0e9295f51 h1:FpQj1XfYBIm4LAml+LDjvnfjDs20cSDsFmgjThOqmks=
|
||||
github.com/metacubex/sing-quic v0.0.0-20260726014900-38b0e9295f51/go.mod h1:6ayFGfzzBE85csgQkM3gf4neFq6s0losHlPRSxY+nuk=
|
||||
github.com/metacubex/sing-shadowsocks v0.2.12 h1:Wqzo8bYXrK5aWqxu/TjlTnYZzAKtKsaFQBdr6IHFaBE=
|
||||
github.com/metacubex/sing-shadowsocks v0.2.12/go.mod h1:2e5EIaw0rxKrm1YTRmiMnDulwbGxH9hAFlrwQLQMQkU=
|
||||
github.com/metacubex/sing-shadowsocks2 v0.2.7 h1:hSuuc0YpsfiqYqt1o+fP4m34BQz4e6wVj3PPBVhor3A=
|
||||
github.com/metacubex/sing-shadowsocks2 v0.2.7/go.mod h1:vOEbfKC60txi0ca+yUlqEwOGc3Obl6cnSgx9Gf45KjE=
|
||||
github.com/metacubex/sing-tun v0.4.22 h1:6ARRJ2BIFD1u4r/DTMNcxaNuGyimfXEeUyD4iFJRaZs=
|
||||
github.com/metacubex/sing-tun v0.4.22/go.mod h1:9T0SN+FHvzRrn7hgl2kmXy5v8xzzLKW1hITButIPAco=
|
||||
github.com/metacubex/sing-quic v0.0.0-20260904234848-1c242664697a h1:qZzQwtWDrPU1LXoMow0/iLHY3mb7uauS/mho8hYcenE=
|
||||
github.com/metacubex/sing-quic v0.0.0-20260904234848-1c242664697a/go.mod h1:6ayFGfzzBE85csgQkM3gf4neFq6s0losHlPRSxY+nuk=
|
||||
github.com/metacubex/sing-shadowsocks v0.2.13 h1:PKmInHy9+4nY3FLhmKcWdASUBo9WZ/KrSPWjNpGaUTQ=
|
||||
github.com/metacubex/sing-shadowsocks v0.2.13/go.mod h1:2e5EIaw0rxKrm1YTRmiMnDulwbGxH9hAFlrwQLQMQkU=
|
||||
github.com/metacubex/sing-shadowsocks2 v0.2.8 h1:6MoODu7BAcnwIC0w9muq+2LZVz01wWD07L5IRhOty9Q=
|
||||
github.com/metacubex/sing-shadowsocks2 v0.2.8/go.mod h1:vOEbfKC60txi0ca+yUlqEwOGc3Obl6cnSgx9Gf45KjE=
|
||||
github.com/metacubex/sing-tun v0.4.24 h1:2H/RhlwQxUgc1K8h46giwV5uTUovx+DhQGpVPlUB4Yo=
|
||||
github.com/metacubex/sing-tun v0.4.24/go.mod h1:YLk0jq8ITecb0LlNKAXC+NTkYTYmvihQxFAR495LN8c=
|
||||
github.com/metacubex/sing-vmess v0.2.5 h1:m9Zt5I27lB9fmLMZfism9sH2LcnAfShZfwSkf6/KJoE=
|
||||
github.com/metacubex/sing-vmess v0.2.5/go.mod h1:AwtlzUgf8COe9tRYAKqWZ+leDH7p5U98a0ZUpYehl8Q=
|
||||
github.com/metacubex/sing-wireguard v0.0.0-20260810013230-110eac03c3f0 h1:4g/ObGTfHaY2zXcFjKOUO9aAFk+yFjxsZfUPQPG6J8c=
|
||||
github.com/metacubex/sing-wireguard v0.0.0-20260810013230-110eac03c3f0/go.mod h1:H27NH3IgI4qk1Wj8kS9pneNMJYEuxFskcQfkfVW9vnw=
|
||||
github.com/metacubex/sing-wireguard v0.0.0-20260826105301-c3ae17d19f9e h1:KGKZt//rrELT/dEwgYcHCqNYEGr3a7uVutlZrzrZZWY=
|
||||
github.com/metacubex/sing-wireguard v0.0.0-20260826105301-c3ae17d19f9e/go.mod h1:OiYj4hitKrN1YIrgGHXp6zKnc9asCspF+SrYlLN2hYQ=
|
||||
github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 h1:DK2l6m2Fc85H2BhiAPgbJygiWhesPlfGmF+9Vw6ARdk=
|
||||
github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141/go.mod h1:/yI4OiGOSn0SURhZdJF3CbtPg3nwK700bG8TZLMBvAg=
|
||||
github.com/metacubex/ssh v0.1.0 h1:iGfr99qk/eMHzUnQ/0bTxXT8+8SWqLSHBWDHoAhngzw=
|
||||
github.com/metacubex/ssh v0.1.0/go.mod h1:NUtl0d+/f2cG9ECEpMM8iCVOpmggQlC13oLeDUONDlU=
|
||||
github.com/metacubex/tailscale v0.0.0-20260807072706-a4fb5feabcbb h1:EE4+ehlygfki5qJvkIYs6zJXhv7WFAX+4cDQi/b6cY8=
|
||||
github.com/metacubex/tailscale v0.0.0-20260807072706-a4fb5feabcbb/go.mod h1:fMMYScJPYt8Ss3D4VhontA2uV2N9NPZbbXsPP6LgQeo=
|
||||
github.com/metacubex/tailscale v0.0.0-20260821153257-ff0ecd818181 h1:hWsSSzuCHe+WvPV1zhoJ8gXTXbp/5nhpPKxtpqgIXu0=
|
||||
github.com/metacubex/tailscale v0.0.0-20260821153257-ff0ecd818181/go.mod h1:fMMYScJPYt8Ss3D4VhontA2uV2N9NPZbbXsPP6LgQeo=
|
||||
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260725073821-e61ab99cede2 h1:nKyLVJEU4jsLVx+rdqbDUbEKTX/Sdu161AeVoZC9tlY=
|
||||
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260725073821-e61ab99cede2/go.mod h1:W00EeoPdd2jbV3+ZUa0m2b7FdKx5B1LFsXvEpy7cToQ=
|
||||
github.com/metacubex/tfo-go v0.0.0-20260623020846-376a77860b8c h1:+2C1UshSLzaogLY/IDz1XhOLxjhs3rkhVHB4eCN9Zto=
|
||||
@@ -190,6 +191,8 @@ github.com/metacubex/tls v0.1.8 h1:BHF2payjtxC7wdR3tnq8vl3FAgEQB6/pPjP2tJNTsP4=
|
||||
github.com/metacubex/tls v0.1.8/go.mod h1:0XeVdL0cBw+8i5Hqy3lVeP9IyD/LFTq02ExvHM6rzEM=
|
||||
github.com/metacubex/utls v1.8.7 h1:Cp+yWkNTFkSihETgGWq34hlVFds5HpYWVOR1xovUVTs=
|
||||
github.com/metacubex/utls v1.8.7/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko=
|
||||
github.com/metacubex/wazero v0.0.0-20260628025728-9ae6bdcf2a7d h1:UMFI+kdp0jA8s9tC7oul0pWeTW6s8lnm0dD5PT+RY14=
|
||||
github.com/metacubex/wazero v0.0.0-20260628025728-9ae6bdcf2a7d/go.mod h1:p48xp436h1oGfoXuEnVONeDhTW+E2UpY94GAVxA62oI=
|
||||
github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f h1:FGBPRb1zUabhPhDrlKEjQ9lgIwQ6cHL4x8M9lrERhbk=
|
||||
github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f/go.mod h1:oPGcV994OGJedmmxrcK9+ni7jUEMGhR+uVQAdaduIP4=
|
||||
github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49 h1:lhlqpYHopuTLx9xQt22kSA9HtnyTDmk5XjjQVCGHe2E=
|
||||
@@ -217,7 +220,6 @@ github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcR
|
||||
github.com/pires/go-proxyproto v0.8.0 h1:5unRmEAPbHXHuLjDg01CxJWf91cw3lKHc/0xzKpXEe0=
|
||||
github.com/pires/go-proxyproto v0.8.0/go.mod h1:iknsfgnH8EkjrMeMyvfKByp9TiBZCKZM0jx2xmKqnVY=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e h1:dCWirM5F3wMY+cmRda/B1BiPsFtmzXqV9b0hLWtVBMs=
|
||||
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e/go.mod h1:9leZcVcItj6m9/CfHY5Em/iBrCz7js8LcRQGTKEEv2M=
|
||||
@@ -250,8 +252,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE=
|
||||
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
|
||||
@@ -287,6 +289,8 @@ gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec h1:FpfFs4EhNehiV
|
||||
gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec/go.mod h1:BZ1RAoRPbCxum9Grlv5aeksu2H8BiKehBYooU2LFiOQ=
|
||||
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
|
||||
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||
@@ -333,10 +337,7 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
|
||||
@@ -3,7 +3,12 @@ package main
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"maps"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -12,12 +17,13 @@ import (
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/metacubex/mihomo/adapter"
|
||||
"github.com/metacubex/mihomo/adapter/outboundgroup"
|
||||
"github.com/metacubex/mihomo/common/observable"
|
||||
"github.com/metacubex/mihomo/common/utils"
|
||||
"github.com/metacubex/mihomo/component/resolver"
|
||||
@@ -25,9 +31,12 @@ import (
|
||||
"github.com/metacubex/mihomo/config"
|
||||
"github.com/metacubex/mihomo/constant"
|
||||
"github.com/metacubex/mihomo/constant/features"
|
||||
cp "github.com/metacubex/mihomo/constant/provider"
|
||||
"github.com/metacubex/mihomo/dns"
|
||||
"github.com/metacubex/mihomo/hub/executor"
|
||||
"github.com/metacubex/mihomo/listener"
|
||||
"github.com/metacubex/mihomo/log"
|
||||
rp "github.com/metacubex/mihomo/rules/provider"
|
||||
"github.com/metacubex/mihomo/tunnel"
|
||||
"github.com/metacubex/mihomo/tunnel/statistic"
|
||||
)
|
||||
@@ -54,6 +63,7 @@ func handleStartListener() bool {
|
||||
isRunning.Store(true)
|
||||
updateListeners(currentConfig)
|
||||
resolver.ResetConnection()
|
||||
refreshRoute()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -81,6 +91,7 @@ func handleForceGC() {
|
||||
|
||||
func handleShutdown() bool {
|
||||
handleStopLog()
|
||||
stopRouteWatch()
|
||||
|
||||
configMu.Lock()
|
||||
isRunning.Store(false)
|
||||
@@ -106,6 +117,33 @@ func handleValidateConfig(path string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Tailscale and EasyTier register a DNS client under the proxy name when built
|
||||
// and drop that name's entry on Close, so a probe under the real name would
|
||||
// take the resolver from a running proxy of the same name.
|
||||
const validationNameSuffix = "\x00validate"
|
||||
|
||||
// The per-proxy parser the config load runs; checks across proxies, such as
|
||||
// duplicate names, are left to the load itself.
|
||||
func handleValidateProxies(mappings []map[string]any) []string {
|
||||
results := make([]string, len(mappings))
|
||||
for i, mapping := range mappings {
|
||||
name, _ := mapping["name"].(string)
|
||||
probe := mapping
|
||||
switch mapping["type"] {
|
||||
case "tailscale", "easytier":
|
||||
probe = maps.Clone(mapping)
|
||||
probe["name"] = name + validationNameSuffix
|
||||
}
|
||||
proxy, err := adapter.ParseProxy(probe)
|
||||
if err != nil {
|
||||
results[i] = strings.ReplaceAll(err.Error(), validationNameSuffix, "")
|
||||
continue
|
||||
}
|
||||
_ = proxy.Close()
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
const globalProxyName = "GLOBAL"
|
||||
|
||||
func isProxyGroupType(adapterType constant.AdapterType) bool {
|
||||
@@ -155,12 +193,35 @@ func handleGetProxies() ProxiesData {
|
||||
return p.Type(), true
|
||||
})
|
||||
|
||||
views := make(map[string]any, len(proxies))
|
||||
for name, proxy := range proxies {
|
||||
views[name] = proxyView(proxy)
|
||||
}
|
||||
return ProxiesData{
|
||||
All: allNames,
|
||||
Proxies: proxies,
|
||||
Proxies: views,
|
||||
}
|
||||
}
|
||||
|
||||
// Proxy.MarshalJSON encodes each node twice, with history the host never reads.
|
||||
func proxyView(proxy constant.Proxy) any {
|
||||
node := nodeView{Name: proxy.Name(), Type: proxy.Type().String()}
|
||||
if !isProxyGroupType(proxy.Type()) {
|
||||
return node
|
||||
}
|
||||
data, err := proxy.Adapter().MarshalJSON()
|
||||
view := map[string]any{}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(data, &view)
|
||||
}
|
||||
if err != nil {
|
||||
log.Warnln("[APP] encode group %s: %v", node.Name, err)
|
||||
return node
|
||||
}
|
||||
view["name"] = node.Name
|
||||
return view
|
||||
}
|
||||
|
||||
var (
|
||||
errGroupNotFound = errors.New("Not found group")
|
||||
errGroupInvalidType = errors.New("Group has invalid proxy type")
|
||||
@@ -171,7 +232,7 @@ func lookupProxy(name string) constant.Proxy {
|
||||
return tunnel.AllProxies()[name]
|
||||
}
|
||||
|
||||
func selectableGroup(groupName string) (outboundgroup.SelectAble, error) {
|
||||
func selectableGroup(groupName string) (pickableGroup, error) {
|
||||
group := lookupProxy(groupName)
|
||||
if group == nil {
|
||||
return nil, errGroupNotFound
|
||||
@@ -180,29 +241,30 @@ func selectableGroup(groupName string) (outboundgroup.SelectAble, error) {
|
||||
if !ok {
|
||||
return nil, errGroupInvalidType
|
||||
}
|
||||
selector, ok := adapterProxy.ProxyAdapter.(outboundgroup.SelectAble)
|
||||
selector, ok := adapterProxy.ProxyAdapter.(pickableGroup)
|
||||
if !ok {
|
||||
return nil, errGroupNotSelect
|
||||
}
|
||||
return selector, nil
|
||||
}
|
||||
|
||||
func handleChangeProxy(params *ChangeProxyParams) string {
|
||||
func handleChangeProxy(params *ChangeProxyParams) *ChangeProxyResult {
|
||||
selectMu.Lock()
|
||||
defer selectMu.Unlock()
|
||||
|
||||
selector, err := selectableGroup(params.GroupName)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
return &ChangeProxyResult{Message: err.Error()}
|
||||
}
|
||||
before := selector.Now()
|
||||
if params.ProxyName == "" {
|
||||
selector.ForceSet(params.ProxyName)
|
||||
return ""
|
||||
} else if err := selector.Set(params.ProxyName); err != nil {
|
||||
return &ChangeProxyResult{Message: err.Error()}
|
||||
}
|
||||
if err := selector.Set(params.ProxyName); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
changed := selector.Now() != before
|
||||
refreshRouteLocked(false)
|
||||
return &ChangeProxyResult{Changed: changed}
|
||||
}
|
||||
|
||||
func handleGetTraffic(onlyStatisticsProxy bool) Traffic {
|
||||
@@ -307,6 +369,15 @@ func handleGetConnections() *statistic.Snapshot {
|
||||
return statistic.DefaultManager.Snapshot()
|
||||
}
|
||||
|
||||
func handleGetConnectionCount() int {
|
||||
count := 0
|
||||
statistic.DefaultManager.Range(func(statistic.Tracker) bool {
|
||||
count++
|
||||
return true
|
||||
})
|
||||
return count
|
||||
}
|
||||
|
||||
func handleCloseConnections() bool {
|
||||
statistic.DefaultManager.Range(func(c statistic.Tracker) bool {
|
||||
_ = c.Close()
|
||||
@@ -440,20 +511,65 @@ func handleUpdateGeoData(geoType string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func providerRequestErrorCode(err error) string {
|
||||
type providerRequestFailure struct {
|
||||
code string
|
||||
reason string
|
||||
statusCode int
|
||||
}
|
||||
|
||||
// The reason values are matched by lib/common/network_error.dart.
|
||||
func classifyProviderRequestError(err error) (providerRequestFailure, bool) {
|
||||
// mihomo reports a non-2xx fetch as errors.New(resp.Status).
|
||||
message := err.Error()
|
||||
if len(message) >= 4 && message[3] == ' ' {
|
||||
status, parseErr := strconv.Atoi(message[:3])
|
||||
if parseErr == nil && status >= 100 && status <= 599 {
|
||||
return "request_bad_response"
|
||||
return providerRequestFailure{
|
||||
code: "request_bad_response",
|
||||
statusCode: status,
|
||||
}, true
|
||||
}
|
||||
}
|
||||
var urlError *url.Error
|
||||
var networkError net.Error
|
||||
if errors.Is(err, context.DeadlineExceeded) ||
|
||||
if reason := providerRequestFailureReason(err); reason != "" ||
|
||||
errors.As(err, &urlError) ||
|
||||
errors.As(err, &networkError) {
|
||||
return "request_error"
|
||||
return providerRequestFailure{code: "request_error", reason: reason}, true
|
||||
}
|
||||
return providerRequestFailure{}, false
|
||||
}
|
||||
|
||||
func providerRequestFailureReason(err error) string {
|
||||
var unknownAuthority x509.UnknownAuthorityError
|
||||
var hostname x509.HostnameError
|
||||
var invalidCertificate x509.CertificateInvalidError
|
||||
var certificateVerification *tls.CertificateVerificationError
|
||||
var recordHeader tls.RecordHeaderError
|
||||
var alert tls.AlertError
|
||||
var dnsError *net.DNSError
|
||||
var networkError net.Error
|
||||
var opError *net.OpError
|
||||
switch {
|
||||
case errors.As(err, &unknownAuthority),
|
||||
errors.As(err, &hostname),
|
||||
errors.As(err, &invalidCertificate),
|
||||
errors.As(err, &certificateVerification),
|
||||
errors.As(err, &recordHeader),
|
||||
errors.As(err, &alert):
|
||||
return "tls"
|
||||
case errors.As(err, &dnsError), errors.Is(err, resolver.ErrIPNotFound):
|
||||
return "dns"
|
||||
case errors.Is(err, context.DeadlineExceeded),
|
||||
errors.Is(err, os.ErrDeadlineExceeded),
|
||||
errors.As(err, &networkError) && networkError.Timeout():
|
||||
return "timeout"
|
||||
case errors.As(err, &opError),
|
||||
errors.Is(err, syscall.ECONNREFUSED),
|
||||
errors.Is(err, syscall.ECONNRESET),
|
||||
errors.Is(err, io.EOF),
|
||||
errors.Is(err, io.ErrUnexpectedEOF):
|
||||
return "connection"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -466,6 +582,25 @@ func providerMethodError(code, providerName string, err error) *MethodError {
|
||||
}
|
||||
}
|
||||
|
||||
func providerRequestMethodError(
|
||||
failure providerRequestFailure,
|
||||
providerName string,
|
||||
err error,
|
||||
) *MethodError {
|
||||
details := map[string]any{"providerName": providerName}
|
||||
if failure.reason != "" {
|
||||
details["reason"] = failure.reason
|
||||
}
|
||||
if failure.statusCode != 0 {
|
||||
details["statusCode"] = failure.statusCode
|
||||
}
|
||||
return &MethodError{
|
||||
Code: failure.code,
|
||||
Message: err.Error(),
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateExternalProvider(providerName string) *MethodError {
|
||||
p, exist := lookupExternalProvider(providerName)
|
||||
if !exist {
|
||||
@@ -485,12 +620,12 @@ func handleUpdateExternalProvider(providerName string) *MethodError {
|
||||
}
|
||||
defer releaseUpdate(key)
|
||||
if err := p.Update(); err != nil {
|
||||
code := providerRequestErrorCode(err)
|
||||
if code == "" {
|
||||
code = "provider_update_error"
|
||||
if failure, ok := classifyProviderRequestError(err); ok {
|
||||
return providerRequestMethodError(failure, providerName, err)
|
||||
}
|
||||
return providerMethodError(code, providerName, err)
|
||||
return providerMethodError("provider_update_error", providerName, err)
|
||||
}
|
||||
refreshRoute()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -515,52 +650,10 @@ func handleSideLoadExternalProvider(providerName string, data []byte) *MethodErr
|
||||
if err := sideUpdateExternalProvider(p, data); err != nil {
|
||||
return providerMethodError("provider_update_error", providerName, err)
|
||||
}
|
||||
refreshRoute()
|
||||
return nil
|
||||
}
|
||||
|
||||
// defaultRefreshHealthChecks re-probes every proxy provider off the calling
|
||||
// thread. Providers coalesce concurrent checks internally, so an extra call
|
||||
// costs nothing when one is already running.
|
||||
func defaultRefreshHealthChecks() {
|
||||
safeGoDetached("refreshHealthChecks", func() {
|
||||
for name, p := range tunnel.ProvidersSnapshot() {
|
||||
log.Debugln("[APP] re-checking provider %s after resume", name)
|
||||
p.HealthCheck()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var refreshHealthChecks = defaultRefreshHealthChecks
|
||||
|
||||
func handleSuspend(suspended bool) bool {
|
||||
wasSuspended := isSuspended.Swap(suspended)
|
||||
if suspended {
|
||||
tunnel.OnSuspend()
|
||||
return true
|
||||
}
|
||||
|
||||
tunnel.OnRunning()
|
||||
// Provider health checks keep ticking through Doze, where the app has no
|
||||
// network at all, so coming back means every proxy is marked dead and every
|
||||
// delay reads Timeout. A lazy provider then skips its next tick because
|
||||
// nothing touched it in the meantime, and the whole list stays wrong until
|
||||
// the user tests by hand. Re-check now instead - but not while the
|
||||
// listeners are stopped, since the service also resumes the core on its way
|
||||
// down.
|
||||
if wasSuspended && isRunning.Load() {
|
||||
refreshHealthChecks()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// A failure measured while the device is dozing says nothing about the node -
|
||||
// the app had no network at all - and publishing it repaints the entire list as
|
||||
// Timeout for a user who is not even looking. Successes still are worth having,
|
||||
// whenever they happen.
|
||||
func shouldPublishDelay(delay uint16) bool {
|
||||
return delay != 0 || !isSuspended.Load()
|
||||
}
|
||||
|
||||
func handleStartLog() {
|
||||
logMu.Lock()
|
||||
if logCancel != nil {
|
||||
@@ -620,8 +713,18 @@ func handleStopLog() {
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetMemory() uint64 {
|
||||
return statistic.DefaultManager.Memory()
|
||||
// HeapIdle still counts spans the runtime has already handed back to the OS,
|
||||
// so the retained-but-unused figure subtracts HeapReleased.
|
||||
func handleGetMemoryStats() MemoryStats {
|
||||
var stats runtime.MemStats
|
||||
runtime.ReadMemStats(&stats)
|
||||
return MemoryStats{
|
||||
Rss: statistic.DefaultManager.Memory(),
|
||||
HeapInuse: stats.HeapInuse,
|
||||
HeapIdle: stats.HeapIdle - stats.HeapReleased,
|
||||
StackInuse: stats.StackInuse,
|
||||
RuntimeOther: stats.MSpanInuse + stats.MCacheInuse + stats.BuckHashSys + stats.GCSys + stats.OtherSys,
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetConfig(path string) (*config.RawConfig, error) {
|
||||
@@ -632,6 +735,24 @@ func handleGetConfig(path string) (*config.RawConfig, error) {
|
||||
return config.UnmarshalRawConfig(buf)
|
||||
}
|
||||
|
||||
func handleDumpRuleSet(path string) (string, error) {
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mrsBehaviors := []cp.RuleBehavior{cp.Domain, cp.IPCIDR}
|
||||
var errs []error
|
||||
for _, behavior := range mrsBehaviors {
|
||||
var text strings.Builder
|
||||
err := rp.ConvertToMrs(buf, behavior, cp.MrsRule, &text)
|
||||
if err == nil {
|
||||
return text.String(), nil
|
||||
}
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return "", errors.Join(errs...)
|
||||
}
|
||||
|
||||
func handleCrash() {
|
||||
panic("handle invoke crash")
|
||||
}
|
||||
@@ -682,9 +803,6 @@ func handleSetupConfig(params *SetupParams) string {
|
||||
|
||||
func init() {
|
||||
adapter.UrlTestHook = func(url string, name string, delay uint16) {
|
||||
if !shouldPublishDelay(delay) {
|
||||
return
|
||||
}
|
||||
sendMessage(Message{
|
||||
Type: DelayMessage,
|
||||
Data: &Delay{
|
||||
@@ -695,11 +813,18 @@ func init() {
|
||||
})
|
||||
}
|
||||
statistic.DefaultRequestNotify = func(c statistic.Tracker) {
|
||||
notifyProbeRoute(c)
|
||||
sendMessage(Message{
|
||||
Type: RequestMessage,
|
||||
Data: c,
|
||||
})
|
||||
}
|
||||
dns.DefaultQueryNotify = func(record dns.QueryRecord) {
|
||||
sendMessage(Message{
|
||||
Type: DnsMessage,
|
||||
Data: newDnsQuery(record),
|
||||
})
|
||||
}
|
||||
executor.DefaultProviderLoadedHook = func(providerName string) {
|
||||
scheduleReclaimOwnership()
|
||||
sendMessage(Message{
|
||||
@@ -713,6 +838,9 @@ func init() {
|
||||
} else {
|
||||
releaseGeoUpdateFromHook(geoType)
|
||||
scheduleReclaimOwnership()
|
||||
if !skipped && updateErr == nil {
|
||||
bumpRouteEpoch()
|
||||
}
|
||||
}
|
||||
status := GeoUpdateStatus{
|
||||
Type: geoType,
|
||||
|
||||
@@ -2,8 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -11,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -19,11 +24,15 @@ import (
|
||||
"github.com/metacubex/mihomo/adapter/outboundgroup"
|
||||
"github.com/metacubex/mihomo/adapter/provider"
|
||||
"github.com/metacubex/mihomo/common/utils"
|
||||
"github.com/metacubex/mihomo/component/resolver"
|
||||
"github.com/metacubex/mihomo/config"
|
||||
"github.com/metacubex/mihomo/constant"
|
||||
cp "github.com/metacubex/mihomo/constant/provider"
|
||||
"github.com/metacubex/mihomo/dns"
|
||||
"github.com/metacubex/mihomo/log"
|
||||
rp "github.com/metacubex/mihomo/rules/provider"
|
||||
"github.com/metacubex/mihomo/tunnel"
|
||||
D "github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func namedProxy(name string) constant.Proxy {
|
||||
@@ -96,82 +105,6 @@ func TestPatchSelectGroupRestoresASelectionWithoutValidatingIt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Coming out of Doze has to re-probe. The health checks that ran while the app
|
||||
// had no network at all left every proxy marked dead and every delay reading
|
||||
// Timeout, and a lazy provider skips its next tick because nothing touched it
|
||||
// in the meantime.
|
||||
func TestHandleSuspendRefreshesHealthChecksOnResume(t *testing.T) {
|
||||
var refreshes atomic.Int32
|
||||
|
||||
previous := refreshHealthChecks
|
||||
previousRunning := isRunning.Load()
|
||||
refreshHealthChecks = func() { refreshes.Add(1) }
|
||||
t.Cleanup(func() {
|
||||
refreshHealthChecks = previous
|
||||
isRunning.Store(previousRunning)
|
||||
isSuspended.Store(false)
|
||||
tunnel.OnRunning()
|
||||
})
|
||||
|
||||
isSuspended.Store(false)
|
||||
isRunning.Store(true)
|
||||
|
||||
handleSuspend(false)
|
||||
if got := refreshes.Load(); got != 0 {
|
||||
t.Errorf("refreshes = %d, want none: the device was never suspended", got)
|
||||
}
|
||||
|
||||
handleSuspend(true)
|
||||
if !isSuspended.Load() {
|
||||
t.Error("handleSuspend(true) did not record the suspension")
|
||||
}
|
||||
if got := refreshes.Load(); got != 0 {
|
||||
t.Errorf("refreshes = %d, want none while the device is still suspended", got)
|
||||
}
|
||||
|
||||
handleSuspend(false)
|
||||
if isSuspended.Load() {
|
||||
t.Error("handleSuspend(false) did not clear the suspension")
|
||||
}
|
||||
if got := refreshes.Load(); got != 1 {
|
||||
t.Errorf("refreshes = %d, want exactly one on resume", got)
|
||||
}
|
||||
|
||||
handleSuspend(false)
|
||||
if got := refreshes.Load(); got != 1 {
|
||||
t.Errorf("refreshes = %d, want a redundant resume to change nothing", got)
|
||||
}
|
||||
|
||||
// The service resumes the core on its way down, and probing every node
|
||||
// through a teardown only produces failures nobody asked for.
|
||||
isRunning.Store(false)
|
||||
handleSuspend(true)
|
||||
handleSuspend(false)
|
||||
if got := refreshes.Load(); got != 1 {
|
||||
t.Errorf("refreshes = %d, want no probe while the listeners are stopped", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldPublishDelayDropsFailuresMeasuredInDoze(t *testing.T) {
|
||||
t.Cleanup(func() { isSuspended.Store(false) })
|
||||
|
||||
isSuspended.Store(false)
|
||||
if !shouldPublishDelay(0) {
|
||||
t.Error("a failure measured while the device is awake is a real result")
|
||||
}
|
||||
if !shouldPublishDelay(120) {
|
||||
t.Error("a successful probe while awake must reach the host")
|
||||
}
|
||||
|
||||
isSuspended.Store(true)
|
||||
if shouldPublishDelay(0) {
|
||||
t.Error("a failure measured in Doze says nothing about the node, want it dropped")
|
||||
}
|
||||
if !shouldPublishDelay(120) {
|
||||
t.Error("a probe that succeeded anyway is still worth publishing")
|
||||
}
|
||||
}
|
||||
|
||||
func typeMap(types map[string]constant.AdapterType) func(string) (constant.AdapterType, bool) {
|
||||
return func(name string) (constant.AdapterType, bool) {
|
||||
adapterType, ok := types[name]
|
||||
@@ -371,6 +304,66 @@ func TestHandleValidateConfigAcceptsAValidFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleValidateProxiesReportsEachProxyInPlace(t *testing.T) {
|
||||
got := handleValidateProxies([]map[string]any{
|
||||
{"name": "ok", "type": "socks5", "server": "127.0.0.1", "port": 1080},
|
||||
{"name": "untyped", "server": "127.0.0.1", "port": 1080},
|
||||
{"name": "unknown", "type": "nope"},
|
||||
})
|
||||
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("handleValidateProxies returned %d results, want 3", len(got))
|
||||
}
|
||||
if got[0] != "" {
|
||||
t.Errorf("valid proxy reported %q", got[0])
|
||||
}
|
||||
if !strings.Contains(got[1], "missing type") {
|
||||
t.Errorf("untyped proxy reported %q, want missing type", got[1])
|
||||
}
|
||||
if !strings.Contains(got[2], "unsupport") {
|
||||
t.Errorf("unknown type reported %q, want unsupported", got[2])
|
||||
}
|
||||
}
|
||||
|
||||
type answeringDNSClient struct{}
|
||||
|
||||
func (answeringDNSClient) Address() string { return "answering" }
|
||||
func (answeringDNSClient) ResetConnection() {}
|
||||
func (answeringDNSClient) ExchangeContext(_ context.Context, m *D.Msg) (*D.Msg, error) {
|
||||
return new(D.Msg).SetReply(m), nil
|
||||
}
|
||||
|
||||
func TestHandleValidateProxiesLeavesARunningProxysResolverInPlace(t *testing.T) {
|
||||
t.Cleanup(dns.RegisterEasyTierDnsClient("mesh", answeringDNSClient{}))
|
||||
easyTier := func(name string) map[string]any {
|
||||
return map[string]any{
|
||||
"name": name,
|
||||
"type": "easytier",
|
||||
"network-name": "net",
|
||||
"peers": []any{"tcp://127.0.0.1:11010"},
|
||||
}
|
||||
}
|
||||
|
||||
got := handleValidateProxies([]map[string]any{
|
||||
easyTier("mesh"),
|
||||
easyTier("../../../../../../../../../../outside"),
|
||||
})
|
||||
|
||||
if got[0] != "" {
|
||||
t.Fatalf("easytier proxy reported %q", got[0])
|
||||
}
|
||||
if !strings.Contains(got[1], "/outside") || strings.Contains(got[1], "\x00") {
|
||||
t.Errorf("unsafe state dir reported %q, want the proxy's own name", got[1])
|
||||
}
|
||||
resolvers := dns.NewResolver(dns.Config{
|
||||
Main: []dns.NameServer{{Net: "easytier", Addr: "mesh"}},
|
||||
})
|
||||
query := new(D.Msg).SetQuestion("host.mesh.", D.TypeA)
|
||||
if _, err := resolvers.ExchangeContext(context.Background(), query); err != nil {
|
||||
t.Fatalf("the running proxy lost its resolver: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleValidateConfigReportsAMissingFile(t *testing.T) {
|
||||
got := handleValidateConfig(filepath.Join(t.TempDir(), "absent.yaml"))
|
||||
|
||||
@@ -475,7 +468,7 @@ func TestHandleChangeProxyDoesNotWaitOutAConfigApply(t *testing.T) {
|
||||
|
||||
answered := make(chan string, 1)
|
||||
go func() {
|
||||
answered <- handleChangeProxy(&ChangeProxyParams{GroupName: "absent", ProxyName: "node"})
|
||||
answered <- handleChangeProxy(&ChangeProxyParams{GroupName: "absent", ProxyName: "node"}).Message
|
||||
}()
|
||||
|
||||
select {
|
||||
@@ -598,22 +591,75 @@ func (p *failingProxyProvider) Update() error {
|
||||
return p.err
|
||||
}
|
||||
|
||||
func TestProviderRequestErrorCode(t *testing.T) {
|
||||
func TestClassifyProviderRequestError(t *testing.T) {
|
||||
tests := []struct {
|
||||
err error
|
||||
want string
|
||||
want providerRequestFailure
|
||||
ok bool
|
||||
}{
|
||||
{err: errors.New("503 Service Unavailable"), want: "request_bad_response"},
|
||||
{err: fmt.Errorf("fetch: %w", context.DeadlineExceeded), want: "request_error"},
|
||||
{err: errors.New("proxy 0: unsupported type"), want: ""},
|
||||
{
|
||||
err: errors.New("503 Service Unavailable"),
|
||||
want: providerRequestFailure{code: "request_bad_response", statusCode: 503},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
err: fmt.Errorf("fetch: %w", context.DeadlineExceeded),
|
||||
want: providerRequestFailure{code: "request_error", reason: "timeout"},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
err: &url.Error{Op: "Get", URL: "https://sub.example", Err: &net.DNSError{Err: "no such host", Name: "sub.example"}},
|
||||
want: providerRequestFailure{code: "request_error", reason: "dns"},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
err: fmt.Errorf("dial: %w", resolver.ErrIPNotFound),
|
||||
want: providerRequestFailure{code: "request_error", reason: "dns"},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
err: &url.Error{Op: "Get", URL: "https://sub.example", Err: x509.UnknownAuthorityError{}},
|
||||
want: providerRequestFailure{code: "request_error", reason: "tls"},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
err: &url.Error{Op: "Get", URL: "https://sub.example", Err: &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}},
|
||||
want: providerRequestFailure{code: "request_error", reason: "connection"},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
err: &url.Error{Op: "Get", URL: "https://sub.example", Err: errors.New("unexpected")},
|
||||
want: providerRequestFailure{code: "request_error"},
|
||||
ok: true,
|
||||
},
|
||||
{err: errors.New("proxy 0: unsupported type")},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := providerRequestErrorCode(test.err); got != test.want {
|
||||
t.Errorf("providerRequestErrorCode(%q) = %q, want %q", test.err, got, test.want)
|
||||
got, ok := classifyProviderRequestError(test.err)
|
||||
if got != test.want || ok != test.ok {
|
||||
t.Errorf("classifyProviderRequestError(%q) = %+v, %v, want %+v, %v", test.err, got, ok, test.want, test.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateExternalProviderReportsRequestFailureDetails(t *testing.T) {
|
||||
const name = "subscription"
|
||||
provider := &failingProxyProvider{
|
||||
fakeProxyProvider: fakeProxyProvider{name: name, vehicle: cp.HTTP},
|
||||
err: errors.New("403 Forbidden"),
|
||||
}
|
||||
withTunnelProviders(t, map[string]cp.ProxyProvider{name: provider}, nil)
|
||||
|
||||
methodError := handleUpdateExternalProvider(name)
|
||||
if methodError == nil || methodError.Code != "request_bad_response" {
|
||||
t.Fatalf("methodError = %+v, want request_bad_response", methodError)
|
||||
}
|
||||
details, ok := methodError.Details.(map[string]any)
|
||||
if !ok || details["providerName"] != name || details["statusCode"] != 403 {
|
||||
t.Errorf("details = %#v, want providerName %q and statusCode 403", methodError.Details, name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateExternalProviderCategorizesFailure(t *testing.T) {
|
||||
const name = "subscription"
|
||||
provider := &failingProxyProvider{
|
||||
@@ -823,6 +869,27 @@ func TestForceGCReleasesTheProxyCache(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetMemoryStatsReportsALiveRuntime(t *testing.T) {
|
||||
stats := handleGetMemoryStats()
|
||||
|
||||
if stats.HeapInuse == 0 {
|
||||
t.Error("a running Go program always has heap in use; a zero means the runtime read was dropped")
|
||||
}
|
||||
encoded, err := json.Marshal(stats)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fields map[string]uint64
|
||||
if err := json.Unmarshal(encoded, &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"rss", "heapInuse", "heapIdle", "stackInuse", "runtimeOther"} {
|
||||
if _, ok := fields[key]; !ok {
|
||||
t.Errorf("memory stats lost the %q field the Dart model reads", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllProxiesFollowsAProviderUpdate(t *testing.T) {
|
||||
provider := newCachingProvider("subscription", "node-a")
|
||||
withTunnelProviders(t, map[string]cp.ProxyProvider{"subscription": provider}, nil)
|
||||
@@ -878,3 +945,91 @@ func TestHandleGetProxiesSeesAProviderUpdate(t *testing.T) {
|
||||
t.Error("a proxy the refresh removed is still reported")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyViewKeepsWhatTheHostReads(t *testing.T) {
|
||||
encode := func(proxy constant.Proxy) map[string]any {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(proxyView(proxy))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal %s: %v", proxy.Name(), err)
|
||||
}
|
||||
view := map[string]any{}
|
||||
if err := json.Unmarshal(data, &view); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", proxy.Name(), err)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
node := encode(namedProxy("node-a"))
|
||||
if want := map[string]any{"name": "node-a", "type": "Direct"}; fmt.Sprint(node) != fmt.Sprint(want) {
|
||||
t.Errorf("node view = %v, want %v", node, want)
|
||||
}
|
||||
|
||||
members := []constant.Proxy{namedProxy("node-a"), namedProxy("node-b")}
|
||||
pd, err := provider.NewCompatibleProvider(
|
||||
"group-provider", members, provider.NewHealthCheck(members, "", 0, 0, true, nil),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCompatibleProvider: %v", err)
|
||||
}
|
||||
selector, err := outboundgroup.NewSelector(
|
||||
outboundgroup.GroupCommonOption{Name: "group"},
|
||||
outboundgroup.SelectorOption{},
|
||||
namedProxy("COMPATIBLE"),
|
||||
[]cp.ProxyProvider{pd},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSelector: %v", err)
|
||||
}
|
||||
group := encode(adapter.NewProxy(selector))
|
||||
if group["name"] != "group" || group["type"] != "Selector" || group["now"] != "node-a" {
|
||||
t.Errorf("group view = %v, want its name, type and selection", group)
|
||||
}
|
||||
if fmt.Sprint(group["all"]) != "[node-a node-b]" {
|
||||
t.Errorf("group members = %v, want [node-a node-b]", group["all"])
|
||||
}
|
||||
if _, exist := group["hidden"]; !exist {
|
||||
t.Error("the host filters groups on hidden, so it has to be present")
|
||||
}
|
||||
if _, exist := group["history"]; exist {
|
||||
t.Error("the view still carries delay history")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDumpRuleSetReadsEitherMrsBehavior(t *testing.T) {
|
||||
cases := []struct {
|
||||
behavior cp.RuleBehavior
|
||||
source string
|
||||
want string
|
||||
}{
|
||||
{cp.Domain, "example.com\n+.example.org\n", "+.example.org\nexample.com\n"},
|
||||
{cp.IPCIDR, "10.0.0.0/8\n192.168.1.0/24\n", "10.0.0.0/8\n192.168.1.0/24\n"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
var mrs strings.Builder
|
||||
if err := rp.ConvertToMrs([]byte(c.source), c.behavior, cp.TextRule, &mrs); err != nil {
|
||||
t.Fatalf("encode %s: %v", c.behavior, err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "rules.mrs")
|
||||
if err := os.WriteFile(path, []byte(mrs.String()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := handleDumpRuleSet(path)
|
||||
if err != nil {
|
||||
t.Fatalf("dump %s: %v", c.behavior, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Fatalf("dump %s = %q, want %q", c.behavior, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDumpRuleSetRejectsText(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "rules.txt")
|
||||
if err := os.WriteFile(path, []byte("example.com\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := handleDumpRuleSet(path); err == nil {
|
||||
t.Fatal("dumping a text rule set succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@ func invokeMethod(callback unsafe.Pointer, paramsChar *C.char) {
|
||||
newMethodResponse("", callback).failure("invalid_method_call", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
go handleMethodCall(call, newMethodResponse(call.ID, callback))
|
||||
dispatchMethodCall(call, newMethodResponse(call.ID, callback))
|
||||
}
|
||||
|
||||
//export startTUN
|
||||
@@ -329,14 +329,26 @@ func quickSetup(callback unsafe.Pointer, initParamsChar *C.char, setupParamsChar
|
||||
}()
|
||||
}
|
||||
|
||||
// The listener goes away with the Flutter engine, which never gets to release
|
||||
// the route watch it held, while the service and this core run on.
|
||||
//
|
||||
//export setEventListener
|
||||
func setEventListener(listener unsafe.Pointer) {
|
||||
eventListenerLock.Lock()
|
||||
defer eventListenerLock.Unlock()
|
||||
if eventListener != nil {
|
||||
releaseObject(eventListener)
|
||||
}
|
||||
eventListener = listener
|
||||
eventListenerLock.Unlock()
|
||||
if listener == nil {
|
||||
stopRouteWatch()
|
||||
}
|
||||
}
|
||||
|
||||
func hasEventListener() bool {
|
||||
eventListenerLock.RLock()
|
||||
defer eventListenerLock.RUnlock()
|
||||
return eventListener != nil
|
||||
}
|
||||
|
||||
//export getTotalTraffic
|
||||
@@ -375,11 +387,6 @@ func stopTun() {
|
||||
}
|
||||
}
|
||||
|
||||
//export suspend
|
||||
func suspend(suspended bool) {
|
||||
handleSuspend(suspended)
|
||||
}
|
||||
|
||||
//export forceGC
|
||||
func forceGC() {
|
||||
handleForceGC()
|
||||
|
||||
@@ -29,25 +29,37 @@ const (
|
||||
stateMessageClass messageClass = iota
|
||||
priorityMessageClass
|
||||
bulkMessageClass
|
||||
// Shares the bulk queue but never evicts from it: a lookup burst would
|
||||
// otherwise push out the request events the connection list is built from.
|
||||
yieldingBulkMessageClass
|
||||
)
|
||||
|
||||
func classOfMessage(message Message) messageClass {
|
||||
switch message.Type {
|
||||
case LoadedMessage, GeoUpdateMessage:
|
||||
case LoadedMessage, GeoUpdateMessage, RouteChangedMessage:
|
||||
return stateMessageClass
|
||||
case LogMessage, RequestMessage:
|
||||
return bulkMessageClass
|
||||
case DnsMessage:
|
||||
return yieldingBulkMessageClass
|
||||
default:
|
||||
return priorityMessageClass
|
||||
}
|
||||
}
|
||||
|
||||
// Request and DNS events fire per connection and per query for as long as the
|
||||
// Android service runs, engine or not; unheard, they are not worth encoding.
|
||||
func sendMessage(message Message) {
|
||||
if !hasEventListener() {
|
||||
return
|
||||
}
|
||||
switch classOfMessage(message) {
|
||||
case stateMessageClass:
|
||||
enqueueState(stateMessageQueue, message)
|
||||
case bulkMessageClass:
|
||||
enqueueLatest(bulkMessageQueue, message)
|
||||
case yieldingBulkMessageClass:
|
||||
enqueueState(bulkMessageQueue, message)
|
||||
default:
|
||||
enqueueLatest(priorityMessageQueue, message)
|
||||
}
|
||||
|
||||
@@ -55,11 +55,13 @@ func runBatcherUntilDrained(t *testing.T, state, priority, bulk chan Message) *b
|
||||
|
||||
func TestClassOfMessageRoutesEachTier(t *testing.T) {
|
||||
for messageType, want := range map[MessageType]messageClass{
|
||||
LoadedMessage: stateMessageClass,
|
||||
GeoUpdateMessage: stateMessageClass,
|
||||
DelayMessage: priorityMessageClass,
|
||||
LogMessage: bulkMessageClass,
|
||||
RequestMessage: bulkMessageClass,
|
||||
LoadedMessage: stateMessageClass,
|
||||
GeoUpdateMessage: stateMessageClass,
|
||||
RouteChangedMessage: stateMessageClass,
|
||||
DelayMessage: priorityMessageClass,
|
||||
LogMessage: bulkMessageClass,
|
||||
RequestMessage: bulkMessageClass,
|
||||
DnsMessage: yieldingBulkMessageClass,
|
||||
} {
|
||||
if got := classOfMessage(Message{Type: messageType}); got != want {
|
||||
t.Errorf("classOfMessage(%s) = %d, want %d", messageType, got, want)
|
||||
|
||||
@@ -12,6 +12,17 @@ type MethodCall struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Method CoreMethod `json:"method"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
seq uint64
|
||||
}
|
||||
|
||||
var methodCallSeq atomic.Uint64
|
||||
|
||||
// Each call runs on its own goroutine, so a handler that must honour the
|
||||
// host's latest intent compares seq, stamped here in arrival order; every
|
||||
// transport has to deliver calls in the order the host sent them.
|
||||
func dispatchMethodCall(call *MethodCall, response MethodResponse) {
|
||||
call.seq = methodCallSeq.Add(1)
|
||||
go handleMethodCall(call, response)
|
||||
}
|
||||
|
||||
func (call MethodCall) decodeArguments(target any) error {
|
||||
@@ -172,6 +183,9 @@ var methodHandlers = map[CoreMethod]methodHandler{
|
||||
validateConfigMethod: withArguments(func(path *string, response MethodResponse) {
|
||||
response.success(handleValidateConfig(*path))
|
||||
}),
|
||||
validateProxiesMethod: withArguments(func(mappings *[]map[string]any, response MethodResponse) {
|
||||
response.success(handleValidateProxies(*mappings))
|
||||
}),
|
||||
updateConfigMethod: withArguments(func(params *UpdateParams, response MethodResponse) {
|
||||
response.success(handleUpdateConfig(params))
|
||||
}),
|
||||
@@ -186,6 +200,16 @@ var methodHandlers = map[CoreMethod]methodHandler{
|
||||
}
|
||||
response.success(rawConfig)
|
||||
}),
|
||||
dumpRuleSetMethod: withArguments(func(path *string, response MethodResponse) {
|
||||
safeGo(response, func() {
|
||||
text, err := handleDumpRuleSet(*path)
|
||||
if err != nil {
|
||||
response.failure("core_error", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
response.success(text)
|
||||
})
|
||||
}),
|
||||
getProxiesMethod: withoutArguments(func(response MethodResponse) {
|
||||
response.success(handleGetProxies())
|
||||
}),
|
||||
@@ -209,9 +233,27 @@ var methodHandlers = map[CoreMethod]methodHandler{
|
||||
response.success(handleTestDelay(params))
|
||||
})
|
||||
}),
|
||||
probeMethod: withArguments(func(params *ProbeParams, response MethodResponse) {
|
||||
safeGo(response, func() {
|
||||
response.success(handleProbe(params))
|
||||
})
|
||||
}),
|
||||
outboundIpMethod: withArguments(func(params *OutboundIpParams, response MethodResponse) {
|
||||
safeGo(response, func() {
|
||||
response.success(handleOutboundIp(params))
|
||||
})
|
||||
}),
|
||||
serviceCheckMethod: withArguments(func(params *ServiceCheckParams, response MethodResponse) {
|
||||
safeGo(response, func() {
|
||||
response.success(handleServiceCheck(params))
|
||||
})
|
||||
}),
|
||||
getConnectionsMethod: withoutArguments(func(response MethodResponse) {
|
||||
response.success(handleGetConnections())
|
||||
}),
|
||||
getConnectionCountMethod: withoutArguments(func(response MethodResponse) {
|
||||
response.success(handleGetConnectionCount())
|
||||
}),
|
||||
closeConnectionsMethod: withoutArguments(func(response MethodResponse) {
|
||||
response.success(handleCloseConnections())
|
||||
}),
|
||||
@@ -262,9 +304,9 @@ var methodHandlers = map[CoreMethod]methodHandler{
|
||||
stopListenerMethod: withoutArguments(func(response MethodResponse) {
|
||||
response.success(handleStopListener())
|
||||
}),
|
||||
getMemoryMethod: withoutArguments(func(response MethodResponse) {
|
||||
getMemoryStatsMethod: withoutArguments(func(response MethodResponse) {
|
||||
safeGo(response, func() {
|
||||
response.success(handleGetMemory())
|
||||
response.success(handleGetMemoryStats())
|
||||
})
|
||||
}),
|
||||
clearEffectMethod: withArguments(func(profileId *int64, response MethodResponse) {
|
||||
@@ -272,6 +314,15 @@ var methodHandlers = map[CoreMethod]methodHandler{
|
||||
response.success(handleClearEffect(*profileId))
|
||||
})
|
||||
}),
|
||||
watchRouteMethod: func(call *MethodCall, response MethodResponse) {
|
||||
var watch bool
|
||||
if !decodeMethodArguments(call, response, &watch) {
|
||||
return
|
||||
}
|
||||
safeGo(response, func() {
|
||||
response.success(handleWatchRoute(watch, call.seq))
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
func registerMethod(method CoreMethod, handler methodHandler) {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/metacubex/http"
|
||||
)
|
||||
|
||||
const (
|
||||
// Racing every source at once would take every probe slot.
|
||||
outboundIpConcurrency = 3
|
||||
outboundIpStagger = 800 * time.Millisecond
|
||||
|
||||
outboundIpMaxBody = 4096
|
||||
|
||||
// Kept in step with browserUa in lib/common/constant.dart.
|
||||
browserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
type OutboundIpParams struct {
|
||||
ProxyName string `json:"proxy-name"`
|
||||
Urls []string `json:"urls"`
|
||||
Timeout int64 `json:"timeout"`
|
||||
}
|
||||
|
||||
type OutboundIpResult struct {
|
||||
Url string `json:"url"`
|
||||
Body string `json:"body"`
|
||||
Delay int64 `json:"delay"`
|
||||
Chains []string `json:"chains"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CoreEpoch uint64 `json:"core-epoch"`
|
||||
PicksVersion uint64 `json:"picks-version"`
|
||||
}
|
||||
|
||||
// handleOutboundIp takes the first usable answer and cancels the losers so they
|
||||
// stop occupying probe slots. Url is the source that was asked, not where it
|
||||
// redirected, and Body is raw: Dart owns the sources and their parsers.
|
||||
func handleOutboundIp(params *OutboundIpParams) *OutboundIpResult {
|
||||
epoch, picksVersion := routeStamp()
|
||||
failure := func(kind string) *OutboundIpResult {
|
||||
return &OutboundIpResult{Error: kind, CoreEpoch: epoch, PicksVersion: picksVersion}
|
||||
}
|
||||
if len(params.Urls) == 0 {
|
||||
return failure(probeErrorFailed)
|
||||
}
|
||||
|
||||
timeout := probeTimeout(params.Timeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
answers := make(chan *OutboundIpResult, len(params.Urls))
|
||||
next := 0
|
||||
inFlight := 0
|
||||
start := func() {
|
||||
if next >= len(params.Urls) || inFlight >= outboundIpConcurrency {
|
||||
return
|
||||
}
|
||||
url := params.Urls[next]
|
||||
next++
|
||||
inFlight++
|
||||
go func() {
|
||||
answers <- probeOutboundIpSource(ctx, url, params.ProxyName, timeout)
|
||||
}()
|
||||
}
|
||||
|
||||
start()
|
||||
|
||||
stagger := time.NewTicker(outboundIpStagger)
|
||||
defer stagger.Stop()
|
||||
for inFlight > 0 {
|
||||
select {
|
||||
case answer := <-answers:
|
||||
inFlight--
|
||||
if answer != nil {
|
||||
return answer
|
||||
}
|
||||
start()
|
||||
case <-stagger.C:
|
||||
start()
|
||||
case <-ctx.Done():
|
||||
return failure(probeErrorTimeout)
|
||||
}
|
||||
}
|
||||
return failure(probeErrorFailed)
|
||||
}
|
||||
|
||||
func probeOutboundIpSource(ctx context.Context, url string, proxyName string, timeout time.Duration) *OutboundIpResult {
|
||||
result := runProbe(ctx, probeRequest{
|
||||
method: http.MethodGet,
|
||||
url: url,
|
||||
proxyName: proxyName,
|
||||
headers: map[string]string{"User-Agent": browserUserAgent},
|
||||
timeout: timeout,
|
||||
maxBody: outboundIpMaxBody,
|
||||
})
|
||||
if result == nil || result.Error != "" || result.StatusCode != http.StatusOK || !mentionsIp(result.Body) {
|
||||
return nil
|
||||
}
|
||||
return &OutboundIpResult{
|
||||
Url: url,
|
||||
Body: result.Body,
|
||||
Delay: result.Delay,
|
||||
Chains: result.Chains,
|
||||
CoreEpoch: result.CoreEpoch,
|
||||
PicksVersion: result.PicksVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 6598 space for carrier-grade NAT, which IsPrivate leaves out.
|
||||
var sharedAddressSpace = netip.MustParsePrefix("100.64.0.0/10")
|
||||
|
||||
// A rate-limit or captcha page can still answer 200, and Dart cannot fall back
|
||||
// once the race is decided, so a body without a public address must not win it.
|
||||
func mentionsIp(body string) bool {
|
||||
tokens := strings.FieldsFunc(body, func(r rune) bool {
|
||||
return !(r == '.' || r == ':' || '0' <= r && r <= '9' || 'a' <= r && r <= 'z' || 'A' <= r && r <= 'Z')
|
||||
})
|
||||
for _, token := range tokens {
|
||||
addr, err := netip.ParseAddr(token)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
if addr.IsGlobalUnicast() && !addr.IsPrivate() && !sharedAddressSpace.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHandleOutboundIpTakesTheFirstUsableAnswer(t *testing.T) {
|
||||
withProbeProxies(t, "node-a")
|
||||
slow := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
_, _ = w.Write([]byte("slow"))
|
||||
})
|
||||
empty := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
quick := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("ip=203.0.113.7"))
|
||||
})
|
||||
|
||||
result := handleOutboundIp(&OutboundIpParams{
|
||||
ProxyName: "node-a",
|
||||
Urls: []string{slow.URL, empty.URL, quick.URL},
|
||||
Timeout: 4000,
|
||||
})
|
||||
|
||||
if result.Error != "" {
|
||||
t.Fatalf("error = %q, want none", result.Error)
|
||||
}
|
||||
if result.Body != "ip=203.0.113.7" {
|
||||
t.Errorf("body = %q, want the quick source's", result.Body)
|
||||
}
|
||||
if result.Url != quick.URL {
|
||||
t.Errorf("url = %q, want %q", result.Url, quick.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOutboundIpSkipsAnAnswerWithoutAnAddress(t *testing.T) {
|
||||
withProbeProxies(t, "node-a")
|
||||
limited := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"error":"rate limited"}`))
|
||||
})
|
||||
later := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(`{"ip":"2001:db8::7","country":"JP"}`))
|
||||
})
|
||||
|
||||
result := handleOutboundIp(&OutboundIpParams{
|
||||
ProxyName: "node-a",
|
||||
Urls: []string{limited.URL, later.URL},
|
||||
Timeout: 4000,
|
||||
})
|
||||
|
||||
if result.Url != later.URL {
|
||||
t.Errorf("url = %q, want the source that named an address", result.Url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMentionsIpNeedsAWholeAddress(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"fl=12f\nip=203.0.113.7\nts=1700000000.1": true,
|
||||
`{"ip":"2001:db8::7","country":"JP"}`: true,
|
||||
`{"ip":"::ffff:203.0.113.7"}`: true,
|
||||
`a::after{content:""}`: false,
|
||||
`div::before,li::marker{color:#fff}`: false,
|
||||
`::-webkit-scrollbar{width:0}`: false,
|
||||
`<a href="http://127.0.0.1/login">`: false,
|
||||
}
|
||||
for body, want := range cases {
|
||||
if got := mentionsIp(body); got != want {
|
||||
t.Errorf("mentionsIp(%q) = %v, want %v", body, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMentionsIpSkipsLocalAddresses(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
"ip=192.168.1.1",
|
||||
`{"ip":"10.20.30.40"}`,
|
||||
"ip=172.16.0.9",
|
||||
"ip=100.64.12.34",
|
||||
`{"ip":"fd00::1"}`,
|
||||
`{"ip":"::ffff:192.168.1.1"}`,
|
||||
} {
|
||||
if mentionsIp(body) {
|
||||
t.Errorf("mentionsIp(%q) = true, want false", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOutboundIpCancelsTheLosers(t *testing.T) {
|
||||
withProbeProxies(t, "node-a")
|
||||
var cancelled atomic.Bool
|
||||
losing := probeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
cancelled.Store(true)
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
})
|
||||
winning := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("ip=203.0.113.9"))
|
||||
})
|
||||
|
||||
result := handleOutboundIp(&OutboundIpParams{
|
||||
ProxyName: "node-a",
|
||||
Urls: []string{losing.URL, winning.URL},
|
||||
Timeout: 5000,
|
||||
})
|
||||
|
||||
if result.Body != "ip=203.0.113.9" {
|
||||
t.Fatalf("body = %q, want the winning source's", result.Body)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for !cancelled.Load() && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if !cancelled.Load() {
|
||||
t.Error("the losing request kept running, want it cancelled once the race was won")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOutboundIpReportsWhenEverySourceFails(t *testing.T) {
|
||||
withProbeProxies(t, "node-a")
|
||||
broken := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
|
||||
result := handleOutboundIp(&OutboundIpParams{
|
||||
ProxyName: "node-a",
|
||||
Urls: []string{broken.URL, broken.URL},
|
||||
Timeout: 3000,
|
||||
})
|
||||
|
||||
if result.Error != probeErrorFailed {
|
||||
t.Errorf("error = %q, want %q", result.Error, probeErrorFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOutboundIpRejectsAnEmptySourceList(t *testing.T) {
|
||||
result := handleOutboundIp(&OutboundIpParams{ProxyName: "node-a", Timeout: 1000})
|
||||
if result.Error != probeErrorFailed {
|
||||
t.Errorf("error = %q, want %q", result.Error, probeErrorFailed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/metacubex/http"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
N "github.com/metacubex/mihomo/common/net"
|
||||
"github.com/metacubex/mihomo/component/ca"
|
||||
"github.com/metacubex/mihomo/constant"
|
||||
"github.com/metacubex/mihomo/tunnel"
|
||||
"github.com/metacubex/mihomo/tunnel/statistic"
|
||||
)
|
||||
|
||||
const (
|
||||
probeConcurrency = 8
|
||||
defaultProbeTimeout = 10 * time.Second
|
||||
probeErrorTimeout = "timeout"
|
||||
probeErrorFailed = "failed"
|
||||
|
||||
probeTunnelPoll = 50 * time.Millisecond
|
||||
|
||||
probeScanChunk = 32 * 1024
|
||||
probeScanOverlap = 256
|
||||
)
|
||||
|
||||
var (
|
||||
probeSlots = make(chan struct{}, probeConcurrency)
|
||||
|
||||
pendingProbeRoutes sync.Map
|
||||
)
|
||||
|
||||
type probeRoute struct {
|
||||
chains []string
|
||||
rule string
|
||||
rulePayload string
|
||||
}
|
||||
|
||||
// probeDialer opens one connection per dial, either pinned to a proxy or
|
||||
// routed through the tunnel like any inbound connection, and keeps the route
|
||||
// of the most recent one, which after redirects is the one that produced the
|
||||
// final response.
|
||||
type probeDialer struct {
|
||||
proxy constant.Proxy
|
||||
|
||||
mu sync.Mutex
|
||||
last *routedConn
|
||||
}
|
||||
|
||||
func (d *probeDialer) dial(ctx context.Context, _ string, address string) (net.Conn, error) {
|
||||
metadata := &constant.Metadata{
|
||||
NetWork: constant.TCP,
|
||||
Type: constant.INNER,
|
||||
DNSMode: constant.DNSNormal,
|
||||
Process: constant.MihomoName,
|
||||
}
|
||||
if err := metadata.SetRemoteAddress(address); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.proxy != nil {
|
||||
conn, err := d.proxy.DialContext(ctx, metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.remember(&routedConn{route: &probeRoute{chains: conn.Chains()}})
|
||||
return conn, nil
|
||||
}
|
||||
if err := awaitTunnel(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
local, remote := N.Pipe()
|
||||
routed := &routedConn{Conn: local, metadata: metadata}
|
||||
pendingProbeRoutes.Store(metadata, routed)
|
||||
go tunnel.Tunnel.HandleTCPConn(remote, metadata)
|
||||
d.remember(routed)
|
||||
return routed, nil
|
||||
}
|
||||
|
||||
// Applying a config suspends the tunnel before the route epoch moves, so a
|
||||
// connection refused in that window would come back as a failure the host
|
||||
// still takes for the current route.
|
||||
func awaitTunnel(ctx context.Context) error {
|
||||
for tunnel.Status() == tunnel.Suspend {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(probeTunnelPoll):
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *probeDialer) remember(conn *routedConn) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.last = conn
|
||||
}
|
||||
|
||||
func (d *probeDialer) route() *probeRoute {
|
||||
d.mu.Lock()
|
||||
last := d.last
|
||||
d.mu.Unlock()
|
||||
if last == nil {
|
||||
return nil
|
||||
}
|
||||
return last.resolve()
|
||||
}
|
||||
|
||||
// The tunnel registers the tracker, which settles the route, before it relays
|
||||
// the first byte back, so the request never has to wait for the route.
|
||||
type routedConn struct {
|
||||
net.Conn
|
||||
metadata *constant.Metadata
|
||||
|
||||
mu sync.Mutex
|
||||
route *probeRoute
|
||||
}
|
||||
|
||||
func (c *routedConn) Close() error {
|
||||
pendingProbeRoutes.CompareAndDelete(c.metadata, c)
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
func (c *routedConn) resolve() *probeRoute {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.route
|
||||
}
|
||||
|
||||
func (c *routedConn) settle(route *probeRoute) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.route = route
|
||||
}
|
||||
|
||||
func notifyProbeRoute(tracker statistic.Tracker) {
|
||||
info := tracker.Info()
|
||||
pending, ok := pendingProbeRoutes.LoadAndDelete(info.Metadata)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pending.(*routedConn).settle(&probeRoute{
|
||||
chains: append([]string(nil), info.Chain...),
|
||||
rule: info.Rule,
|
||||
rulePayload: info.RulePayload,
|
||||
})
|
||||
}
|
||||
|
||||
func probeTimeout(millis int64) time.Duration {
|
||||
if millis <= 0 {
|
||||
return defaultProbeTimeout
|
||||
}
|
||||
return time.Duration(millis) * time.Millisecond
|
||||
}
|
||||
|
||||
func probeErrorKind(err error) string {
|
||||
var netErr net.Error
|
||||
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) {
|
||||
return probeErrorTimeout
|
||||
}
|
||||
return probeErrorFailed
|
||||
}
|
||||
|
||||
type probeRequest struct {
|
||||
method string
|
||||
url string
|
||||
proxyName string
|
||||
headers map[string]string
|
||||
body []byte
|
||||
timeout time.Duration
|
||||
maxBody int64
|
||||
|
||||
// A followed redirect loses the Location that Netflix names its region in.
|
||||
noRedirect bool
|
||||
|
||||
// until sees the latest chunk and probeScanOverlap bytes before it.
|
||||
until func(tail string) bool
|
||||
}
|
||||
|
||||
func handleProbe(params *ProbeParams) *ProbeResult {
|
||||
return runProbe(context.Background(), probeRequest{
|
||||
method: http.MethodGet,
|
||||
url: params.Url,
|
||||
proxyName: params.ProxyName,
|
||||
headers: params.Headers,
|
||||
timeout: probeTimeout(params.Timeout),
|
||||
maxBody: params.MaxBody,
|
||||
})
|
||||
}
|
||||
|
||||
// runProbe returns nil when the request never got a slot before its deadline.
|
||||
func runProbe(parent context.Context, req probeRequest) *ProbeResult {
|
||||
result := &ProbeResult{Url: req.url, Chains: []string{}}
|
||||
result.CoreEpoch, result.PicksVersion = routeStamp()
|
||||
fail := func(err error) *ProbeResult {
|
||||
result.Error = probeErrorKind(err)
|
||||
result.Message = err.Error()
|
||||
return result
|
||||
}
|
||||
|
||||
dialer := &probeDialer{}
|
||||
if req.proxyName != "" {
|
||||
dialer.proxy = lookupProxy(req.proxyName)
|
||||
if dialer.proxy == nil {
|
||||
return fail(fmt.Errorf("proxy %q is not part of the applied config", req.proxyName))
|
||||
}
|
||||
}
|
||||
tlsConfig, err := ca.GetTLSConfig(ca.Option{})
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
|
||||
timeout := req.timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultProbeTimeout
|
||||
}
|
||||
queueCtx, cancelQueue := context.WithTimeout(parent, timeout)
|
||||
granted := acquireProbeSlot(queueCtx)
|
||||
cancelQueue()
|
||||
if !granted {
|
||||
return nil
|
||||
}
|
||||
defer releaseProbeSlot()
|
||||
|
||||
ctx, cancel := context.WithTimeout(parent, timeout)
|
||||
defer cancel()
|
||||
|
||||
var body io.Reader
|
||||
if len(req.body) > 0 {
|
||||
body = bytes.NewReader(req.body)
|
||||
}
|
||||
method := req.method
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, method, req.url, body)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
for name, value := range req.headers {
|
||||
request.Header.Set(name, value)
|
||||
}
|
||||
transport := &http.Transport{
|
||||
DialContext: dialer.dial,
|
||||
TLSClientConfig: tlsConfig,
|
||||
TLSHandshakeTimeout: timeout,
|
||||
MaxIdleConns: 1,
|
||||
IdleConnTimeout: timeout,
|
||||
}
|
||||
defer transport.CloseIdleConnections()
|
||||
client := &http.Client{Transport: transport}
|
||||
if req.noRedirect {
|
||||
client.CheckRedirect = func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
defer func() {
|
||||
_ = response.Body.Close()
|
||||
}()
|
||||
result.Delay = time.Since(start).Milliseconds()
|
||||
result.StatusCode = response.StatusCode
|
||||
result.header = response.Header
|
||||
result.Url = response.Request.URL.String()
|
||||
if route := dialer.route(); route != nil {
|
||||
result.Chains = route.chains
|
||||
result.Rule = route.rule
|
||||
result.RulePayload = route.rulePayload
|
||||
}
|
||||
if req.maxBody > 0 {
|
||||
result.Body = readProbeBody(response.Body, req.maxBody, req.until)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func readProbeBody(body io.Reader, maxBody int64, until func(string) bool) string {
|
||||
limited := io.LimitReader(body, maxBody)
|
||||
if until == nil {
|
||||
payload, _ := io.ReadAll(limited)
|
||||
return string(payload)
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
chunk := make([]byte, probeScanChunk)
|
||||
for {
|
||||
n, err := limited.Read(chunk)
|
||||
if n > 0 {
|
||||
start := max(0, buffer.Len()-probeScanOverlap)
|
||||
buffer.Write(chunk[:n])
|
||||
if until(string(buffer.Bytes()[start:])) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return buffer.String()
|
||||
}
|
||||
|
||||
func acquireProbeSlot(ctx context.Context) bool {
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case probeSlots <- struct{}{}:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func releaseProbeSlot() {
|
||||
<-probeSlots
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/metacubex/mihomo/constant"
|
||||
"github.com/metacubex/mihomo/tunnel"
|
||||
)
|
||||
|
||||
func probeServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
func withProbeProxies(t *testing.T, names ...string) {
|
||||
t.Helper()
|
||||
proxies := make(map[string]constant.Proxy, len(names))
|
||||
for _, name := range names {
|
||||
proxies[name] = namedProxy(name)
|
||||
}
|
||||
tunnel.UpdateProxies(proxies, nil)
|
||||
t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) })
|
||||
}
|
||||
|
||||
func TestHandleProbePinsTheNamedProxy(t *testing.T) {
|
||||
var userAgent string
|
||||
server := probeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
userAgent = r.UserAgent()
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte("ip=203.0.113.7\nloc=US\n"))
|
||||
})
|
||||
withProbeProxies(t, "node-a")
|
||||
|
||||
result := handleProbe(&ProbeParams{
|
||||
Url: server.URL + "/trace",
|
||||
ProxyName: "node-a",
|
||||
Headers: map[string]string{"User-Agent": "probe-test"},
|
||||
Timeout: 2000,
|
||||
MaxBody: 1024,
|
||||
})
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("handleProbe = nil, want a result")
|
||||
}
|
||||
if result.Error != "" {
|
||||
t.Fatalf("error = %q (%s), want none", result.Error, result.Message)
|
||||
}
|
||||
if result.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403", result.StatusCode)
|
||||
}
|
||||
if result.Body != "ip=203.0.113.7\nloc=US\n" {
|
||||
t.Errorf("body = %q", result.Body)
|
||||
}
|
||||
if !slices.Equal(result.Chains, []string{"node-a"}) {
|
||||
t.Errorf("chains = %v, want [node-a]", result.Chains)
|
||||
}
|
||||
if result.Url != server.URL+"/trace" {
|
||||
t.Errorf("url = %q", result.Url)
|
||||
}
|
||||
if userAgent != "probe-test" {
|
||||
t.Errorf("user agent = %q, want the header the caller passed", userAgent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProbeFollowsRedirectsAndCapsTheBody(t *testing.T) {
|
||||
server := probeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
http.Redirect(w, r, "/final", http.StatusFound)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(strings.Repeat("x", 100)))
|
||||
})
|
||||
withProbeProxies(t, "node-a")
|
||||
|
||||
result := handleProbe(&ProbeParams{Url: server.URL, ProxyName: "node-a", Timeout: 2000, MaxBody: 8})
|
||||
|
||||
if result == nil || result.Error != "" {
|
||||
t.Fatalf("result = %+v, want a success", result)
|
||||
}
|
||||
if result.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200 after the redirect", result.StatusCode)
|
||||
}
|
||||
if result.Url != server.URL+"/final" {
|
||||
t.Errorf("url = %q, want the redirect target", result.Url)
|
||||
}
|
||||
if len(result.Body) != 8 {
|
||||
t.Errorf("body length = %d, want the cap", len(result.Body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadProbeBodyStopsOnceTheMarkerArrives(t *testing.T) {
|
||||
marker := "\"currentTerritory\":\"JP\""
|
||||
page := strings.Repeat("x", probeScanChunk-10) + marker + strings.Repeat("y", 4*probeScanChunk)
|
||||
var seen int
|
||||
|
||||
body := readProbeBody(strings.NewReader(page), int64(len(page)), func(tail string) bool {
|
||||
seen++
|
||||
return strings.Contains(tail, marker)
|
||||
})
|
||||
|
||||
if !strings.Contains(body, marker) {
|
||||
t.Fatalf("body lost the marker that straddled two chunks")
|
||||
}
|
||||
if len(body) >= len(page) || seen != 2 {
|
||||
t.Errorf("read %d of %d bytes in %d chunks, want it to stop at the marker", len(body), len(page), seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadProbeBodyKeepsTheCapWithoutAMarker(t *testing.T) {
|
||||
page := strings.Repeat("x", 3*probeScanChunk)
|
||||
|
||||
body := readProbeBody(strings.NewReader(page), probeScanChunk+5, func(string) bool { return false })
|
||||
|
||||
if len(body) != probeScanChunk+5 {
|
||||
t.Errorf("body length = %d, want the cap", len(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProbeSkipsTheBodyWhenNotAsked(t *testing.T) {
|
||||
server := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
withProbeProxies(t, "node-a")
|
||||
|
||||
result := handleProbe(&ProbeParams{Url: server.URL, ProxyName: "node-a", Timeout: 2000})
|
||||
|
||||
if result == nil || result.Error != "" {
|
||||
t.Fatalf("result = %+v, want a success", result)
|
||||
}
|
||||
if result.StatusCode != http.StatusNoContent || result.Body != "" {
|
||||
t.Errorf("status = %d body = %q, want 204 with no body", result.StatusCode, result.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProbeReportsATimeout(t *testing.T) {
|
||||
addr := blackHoleServer(t)
|
||||
withProbeProxies(t, "node-a")
|
||||
|
||||
start := time.Now()
|
||||
result := handleProbe(&ProbeParams{Url: "http://" + addr.String(), ProxyName: "node-a", Timeout: 200})
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("handleProbe = nil, want a result")
|
||||
}
|
||||
if result.Error != probeErrorTimeout {
|
||||
t.Errorf("error = %q (%s), want timeout", result.Error, result.Message)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Errorf("took %s, want the caller's budget", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProbeReportsARefusedConnection(t *testing.T) {
|
||||
server := probeServer(t, func(http.ResponseWriter, *http.Request) {})
|
||||
url := server.URL
|
||||
server.Close()
|
||||
withProbeProxies(t, "node-a")
|
||||
|
||||
result := handleProbe(&ProbeParams{Url: url, ProxyName: "node-a", Timeout: 2000})
|
||||
|
||||
if result == nil || result.Error != probeErrorFailed {
|
||||
t.Fatalf("result = %+v, want a failed probe", result)
|
||||
}
|
||||
if result.StatusCode != 0 || result.Message == "" {
|
||||
t.Errorf("status = %d message = %q, want no status and a reason", result.StatusCode, result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProbeRejectsAnUnknownProxyWithoutWaitingForASlot(t *testing.T) {
|
||||
for i := 0; i < probeConcurrency; i++ {
|
||||
probeSlots <- struct{}{}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for i := 0; i < probeConcurrency; i++ {
|
||||
<-probeSlots
|
||||
}
|
||||
})
|
||||
|
||||
done := make(chan *ProbeResult, 1)
|
||||
go func() {
|
||||
done <- handleProbe(&ProbeParams{Url: "http://example.invalid", ProxyName: "missing", Timeout: 1})
|
||||
}()
|
||||
|
||||
select {
|
||||
case result := <-done:
|
||||
if result == nil || result.Error != probeErrorFailed {
|
||||
t.Errorf("result = %+v, want a failed probe", result)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("an unknown proxy queued behind a saturated probe semaphore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProbeGivesUpQueueingOnTheDeadline(t *testing.T) {
|
||||
for i := 0; i < probeConcurrency; i++ {
|
||||
probeSlots <- struct{}{}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for i := 0; i < probeConcurrency; i++ {
|
||||
<-probeSlots
|
||||
}
|
||||
})
|
||||
withProbeProxies(t, "node-a")
|
||||
|
||||
if result := handleProbe(&ProbeParams{Url: "http://example.invalid", ProxyName: "node-a", Timeout: 50}); result != nil {
|
||||
t.Errorf("result = %+v, want nil when no slot was granted", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProbeRoutesThroughTheTunnel(t *testing.T) {
|
||||
server := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("routed"))
|
||||
})
|
||||
withProbeProxies(t, "DIRECT")
|
||||
tunnel.OnRunning()
|
||||
t.Cleanup(tunnel.OnSuspend)
|
||||
|
||||
result := handleProbe(&ProbeParams{Url: server.URL, Timeout: 2000, MaxBody: 64})
|
||||
|
||||
if result == nil || result.Error != "" {
|
||||
t.Fatalf("result = %+v, want a success", result)
|
||||
}
|
||||
if result.StatusCode != http.StatusOK || result.Body != "routed" {
|
||||
t.Errorf("status = %d body = %q", result.StatusCode, result.Body)
|
||||
}
|
||||
if !slices.Equal(result.Chains, []string{"DIRECT"}) {
|
||||
t.Errorf("chains = %v, want the outbound the tunnel picked", result.Chains)
|
||||
}
|
||||
}
|
||||
|
||||
// earlyDataProxy stands in for vmess, vless, Shadowsocks and the others whose
|
||||
// handshake carries the client's first bytes; the tunnel waits 200ms for them.
|
||||
type earlyDataProxy struct {
|
||||
constant.Proxy
|
||||
handshake chan []byte
|
||||
}
|
||||
|
||||
func (p *earlyDataProxy) DialContext(ctx context.Context, metadata *constant.Metadata) (constant.Conn, error) {
|
||||
conn, err := p.Proxy.DialContext(ctx, metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &earlyDataConn{Conn: conn, handshake: p.handshake}, nil
|
||||
}
|
||||
|
||||
type earlyDataConn struct {
|
||||
constant.Conn
|
||||
handshake chan []byte
|
||||
sent atomic.Bool
|
||||
}
|
||||
|
||||
func (c *earlyDataConn) NeedHandshake() bool {
|
||||
return !c.sent.Load()
|
||||
}
|
||||
|
||||
func (c *earlyDataConn) Write(b []byte) (int, error) {
|
||||
if !c.sent.Swap(true) {
|
||||
c.handshake <- slices.Clone(b)
|
||||
}
|
||||
return c.Conn.Write(b)
|
||||
}
|
||||
|
||||
func TestHandleProbeSendsTheRequestWithTheHandshake(t *testing.T) {
|
||||
server := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("routed"))
|
||||
})
|
||||
handshake := make(chan []byte, 1)
|
||||
tunnel.UpdateProxies(map[string]constant.Proxy{
|
||||
"DIRECT": &earlyDataProxy{Proxy: namedProxy("DIRECT"), handshake: handshake},
|
||||
}, nil)
|
||||
t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) })
|
||||
tunnel.OnRunning()
|
||||
t.Cleanup(tunnel.OnSuspend)
|
||||
|
||||
result := handleProbe(&ProbeParams{Url: server.URL, Timeout: 2000, MaxBody: 64})
|
||||
|
||||
if result == nil || result.Error != "" {
|
||||
t.Fatalf("result = %+v, want a success", result)
|
||||
}
|
||||
if first := <-handshake; len(first) == 0 {
|
||||
t.Error("the handshake went out empty: the request waited on the route while the tunnel waited on the request")
|
||||
}
|
||||
if !slices.Equal(result.Chains, []string{"DIRECT"}) {
|
||||
t.Errorf("chains = %v, want the outbound the tunnel picked", result.Chains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProbeWaitsOutASuspendedTunnel(t *testing.T) {
|
||||
server := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("resumed"))
|
||||
})
|
||||
withProbeProxies(t, "DIRECT")
|
||||
tunnel.OnSuspend()
|
||||
t.Cleanup(tunnel.OnSuspend)
|
||||
time.AfterFunc(150*time.Millisecond, tunnel.OnRunning)
|
||||
|
||||
result := handleProbe(&ProbeParams{Url: server.URL, Timeout: 2000, MaxBody: 64})
|
||||
|
||||
if result == nil || result.Error != "" {
|
||||
t.Fatalf("result = %+v, want the answer once the tunnel runs again", result)
|
||||
}
|
||||
if result.Body != "resumed" {
|
||||
t.Errorf("body = %q", result.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProbeTimesOutWhileTheTunnelStaysSuspended(t *testing.T) {
|
||||
server := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("unreachable"))
|
||||
})
|
||||
withProbeProxies(t, "DIRECT")
|
||||
tunnel.OnSuspend()
|
||||
|
||||
result := handleProbe(&ProbeParams{Url: server.URL, Timeout: 200})
|
||||
|
||||
if result == nil || result.Error != probeErrorTimeout {
|
||||
t.Fatalf("result = %+v, want a timeout", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// Five bytes per entry, alpha-3 then alpha-2, sorted by alpha-3.
|
||||
const iso3166Packed = "ABWAWAFGAFAGOAOAIAAIALAAXALBALANDADAREAEARGARARMAMASMASATAAQ" +
|
||||
"ATFTFATGAGAUSAUAUTATAZEAZBDIBIBELBEBENBJBESBQBFABFBGDBDBGRBG" +
|
||||
"BHRBHBHSBSBIHBABLMBLBLRBYBLZBZBMUBMBOLBOBRABRBRBBBBRNBNBTNBT" +
|
||||
"BVTBVBWABWCAFCFCANCACCKCCCHECHCHLCLCHNCNCIVCICMRCMCODCDCOGCG" +
|
||||
"COKCKCOLCOCOMKMCPVCVCRICRCUBCUCUWCWCXRCXCYMKYCYPCYCZECZDEUDE" +
|
||||
"DJIDJDMADMDNKDKDOMDODZADZECUECEGYEGERIERESHEHESPESESTEEETHET" +
|
||||
"FINFIFJIFJFLKFKFRAFRFROFOFSMFMGABGAGBRGBGEOGEGGYGGGHAGHGIBGI" +
|
||||
"GINGNGLPGPGMBGMGNBGWGNQGQGRCGRGRDGDGRLGLGTMGTGUFGFGUMGUGUYGY" +
|
||||
"HKGHKHMDHMHNDHNHRVHRHTIHTHUNHUIDNIDIMNIMINDINIOTIOIRLIEIRNIR" +
|
||||
"IRQIQISLISISRILITAITJAMJMJEYJEJORJOJPNJPKAZKZKENKEKGZKGKHMKH" +
|
||||
"KIRKIKNAKNKORKRKWTKWLAOLALBNLBLBRLRLBYLYLCALCLIELILKALKLSOLS" +
|
||||
"LTULTLUXLULVALVMACMOMAFMFMARMAMCOMCMDAMDMDGMGMDVMVMEXMXMHLMH" +
|
||||
"MKDMKMLIMLMLTMTMMRMMMNEMEMNGMNMNPMPMOZMZMRTMRMSRMSMTQMQMUSMU" +
|
||||
"MWIMWMYSMYMYTYTNAMNANCLNCNERNENFKNFNGANGNICNINIUNUNLDNLNORNO" +
|
||||
"NPLNPNRUNRNZLNZOMNOMPAKPKPANPAPCNPNPERPEPHLPHPLWPWPNGPGPOLPL" +
|
||||
"PRIPRPRKKPPRTPTPRYPYPSEPSPYFPFQATQAREUREROURORUSRURWARWSAUSA" +
|
||||
"SDNSDSENSNSGPSGSGSGSSHNSHSJMSJSLBSBSLESLSLVSVSMRSMSOMSOSPMPM" +
|
||||
"SRBRSSSDSSSTPSTSURSRSVKSKSVNSISWESESWZSZSXMSXSYCSCSYRSYTCATC" +
|
||||
"TCDTDTGOTGTHATHTJKTJTKLTKTKMTMTLSTLTONTOTTOTTTUNTNTURTRTUVTV" +
|
||||
"TWNTWTZATZUGAUGUKRUAUMIUMURYUYUSAUSUZBUZVATVAVCTVCVENVEVGBVG" +
|
||||
"VIRVIVNMVNVUTVUWLFWFWSMWSYEMYEZAFZAZMBZMZWEZW"
|
||||
|
||||
// normalizeRegion folds a country code to the alpha-2 form Dart renders a flag
|
||||
// from, or "" when it is not one. Only Gemini reports alpha-3.
|
||||
func normalizeRegion(code string) string {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
switch len(code) {
|
||||
case 2:
|
||||
if isAsciiUpper(code) {
|
||||
return code
|
||||
}
|
||||
return ""
|
||||
case 3:
|
||||
if !isAsciiUpper(code) {
|
||||
return ""
|
||||
}
|
||||
low, high := 0, len(iso3166Packed)/5
|
||||
for low < high {
|
||||
mid := (low + high) / 2
|
||||
entry := iso3166Packed[mid*5 : mid*5+3]
|
||||
switch {
|
||||
case entry < code:
|
||||
low = mid + 1
|
||||
case entry > code:
|
||||
high = mid
|
||||
default:
|
||||
return iso3166Packed[mid*5+3 : mid*5+5]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func isAsciiUpper(value string) bool {
|
||||
for _, char := range value {
|
||||
if char < 'A' || char > 'Z' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/metacubex/mihomo/adapter"
|
||||
"github.com/metacubex/mihomo/adapter/outboundgroup"
|
||||
"github.com/metacubex/mihomo/tunnel"
|
||||
)
|
||||
|
||||
// mihomo has no hook for a health check moving a URLTest or Fallback pick, so
|
||||
// the picks are re-read on this timer while the host watches.
|
||||
const routePollInterval = time.Second
|
||||
|
||||
type pickableGroup interface {
|
||||
outboundgroup.ProxyGroup
|
||||
outboundgroup.SelectAble
|
||||
}
|
||||
|
||||
type routeTracker struct {
|
||||
mu sync.Mutex
|
||||
epoch uint64
|
||||
picksVersion uint64
|
||||
picks map[string]string
|
||||
providerVersions map[string]uint32
|
||||
ruleSets map[string]any
|
||||
watched bool
|
||||
watchSeq uint64
|
||||
stopPoll context.CancelFunc
|
||||
}
|
||||
|
||||
var (
|
||||
currentRoute routeTracker
|
||||
|
||||
publishRoute = func(state RouteState) {
|
||||
sendMessage(Message{Type: RouteChangedMessage, Data: state})
|
||||
}
|
||||
)
|
||||
|
||||
func routeStateLocked() RouteState {
|
||||
picks := currentRoute.picks
|
||||
if picks == nil {
|
||||
picks = map[string]string{}
|
||||
}
|
||||
return RouteState{
|
||||
CoreEpoch: currentRoute.epoch,
|
||||
PicksVersion: currentRoute.picksVersion,
|
||||
Picks: maps.Clone(picks),
|
||||
}
|
||||
}
|
||||
|
||||
func routeState() RouteState {
|
||||
currentRoute.mu.Lock()
|
||||
defer currentRoute.mu.Unlock()
|
||||
return routeStateLocked()
|
||||
}
|
||||
|
||||
func routeStamp() (epoch, picksVersion uint64) {
|
||||
currentRoute.mu.Lock()
|
||||
defer currentRoute.mu.Unlock()
|
||||
return currentRoute.epoch, currentRoute.picksVersion
|
||||
}
|
||||
|
||||
func readPicks() (map[string]string, map[string]uint32) {
|
||||
picks := map[string]string{}
|
||||
for name, proxy := range tunnel.AllProxies() {
|
||||
outbound, ok := proxy.(*adapter.Proxy)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
group, ok := outbound.ProxyAdapter.(pickableGroup)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
picks[name] = group.Now()
|
||||
}
|
||||
providers := tunnel.ProvidersSnapshot()
|
||||
versions := make(map[string]uint32, len(providers))
|
||||
for name, p := range providers {
|
||||
versions[name] = p.Version()
|
||||
}
|
||||
return picks, versions
|
||||
}
|
||||
|
||||
// mihomo swaps in a new strategy each time a rule set, manually or on its
|
||||
// interval, loads new content.
|
||||
func readRuleSets() map[string]any {
|
||||
providers := tunnel.RuleProvidersSnapshot()
|
||||
ruleSets := make(map[string]any, len(providers))
|
||||
for name, p := range providers {
|
||||
ruleSets[name] = p.Strategy()
|
||||
}
|
||||
return ruleSets
|
||||
}
|
||||
|
||||
// A provider that swapped its list is a structural change: the names may
|
||||
// survive while the servers behind them do not. The caller holds selectMu.
|
||||
func refreshRouteLocked(structural bool) {
|
||||
picks, versions := readPicks()
|
||||
ruleSets := readRuleSets()
|
||||
|
||||
currentRoute.mu.Lock()
|
||||
if currentRoute.providerVersions != nil && !maps.Equal(versions, currentRoute.providerVersions) {
|
||||
structural = true
|
||||
}
|
||||
if currentRoute.ruleSets != nil && !maps.Equal(ruleSets, currentRoute.ruleSets) {
|
||||
structural = true
|
||||
}
|
||||
currentRoute.providerVersions = versions
|
||||
currentRoute.ruleSets = ruleSets
|
||||
if structural {
|
||||
currentRoute.epoch++
|
||||
}
|
||||
changed := structural
|
||||
if !maps.Equal(picks, currentRoute.picks) {
|
||||
currentRoute.picks = picks
|
||||
currentRoute.picksVersion++
|
||||
changed = true
|
||||
}
|
||||
state := routeStateLocked()
|
||||
watched := currentRoute.watched
|
||||
currentRoute.mu.Unlock()
|
||||
|
||||
if changed && watched {
|
||||
publishRoute(state)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshRoute() {
|
||||
selectMu.Lock()
|
||||
defer selectMu.Unlock()
|
||||
refreshRouteLocked(false)
|
||||
}
|
||||
|
||||
func bumpRouteEpoch() {
|
||||
selectMu.Lock()
|
||||
defer selectMu.Unlock()
|
||||
refreshRouteLocked(true)
|
||||
}
|
||||
|
||||
func handleWatchRoute(watch bool, seq uint64) RouteState {
|
||||
refreshRoute()
|
||||
|
||||
currentRoute.mu.Lock()
|
||||
defer currentRoute.mu.Unlock()
|
||||
if seq < currentRoute.watchSeq {
|
||||
return routeStateLocked()
|
||||
}
|
||||
currentRoute.watchSeq = seq
|
||||
currentRoute.watched = watch
|
||||
if watch && currentRoute.stopPoll == nil {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
currentRoute.stopPoll = cancel
|
||||
go pollRoute(ctx)
|
||||
}
|
||||
if !watch {
|
||||
stopRoutePollLocked()
|
||||
}
|
||||
return routeStateLocked()
|
||||
}
|
||||
|
||||
// The stop takes its own place in the call order: a watch dispatched before
|
||||
// it, the newest one included, may still be waiting to run.
|
||||
func stopRouteWatch() {
|
||||
currentRoute.mu.Lock()
|
||||
defer currentRoute.mu.Unlock()
|
||||
currentRoute.watchSeq = max(currentRoute.watchSeq, methodCallSeq.Add(1))
|
||||
currentRoute.watched = false
|
||||
stopRoutePollLocked()
|
||||
}
|
||||
|
||||
func stopRoutePollLocked() {
|
||||
if currentRoute.stopPoll != nil {
|
||||
currentRoute.stopPoll()
|
||||
currentRoute.stopPoll = nil
|
||||
}
|
||||
}
|
||||
|
||||
// With the listeners down the host pins every probe to DIRECT, so the picks
|
||||
// do not matter until they come back.
|
||||
func pollRoute(ctx context.Context) {
|
||||
ticker := time.NewTicker(routePollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if isRunning.Load() {
|
||||
refreshRoute()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/metacubex/mihomo/adapter"
|
||||
"github.com/metacubex/mihomo/adapter/outboundgroup"
|
||||
"github.com/metacubex/mihomo/adapter/provider"
|
||||
"github.com/metacubex/mihomo/component/resource"
|
||||
"github.com/metacubex/mihomo/config"
|
||||
"github.com/metacubex/mihomo/constant"
|
||||
cp "github.com/metacubex/mihomo/constant/provider"
|
||||
"github.com/metacubex/mihomo/rules"
|
||||
rp "github.com/metacubex/mihomo/rules/provider"
|
||||
"github.com/metacubex/mihomo/tunnel"
|
||||
)
|
||||
|
||||
type routeRecorder struct {
|
||||
mu sync.Mutex
|
||||
states []RouteState
|
||||
}
|
||||
|
||||
func (r *routeRecorder) record(state RouteState) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.states = append(r.states, state)
|
||||
}
|
||||
|
||||
func (r *routeRecorder) published() []RouteState {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]RouteState(nil), r.states...)
|
||||
}
|
||||
|
||||
func clearRoute() {
|
||||
stopRouteWatch()
|
||||
currentRoute.mu.Lock()
|
||||
defer currentRoute.mu.Unlock()
|
||||
currentRoute.epoch = 0
|
||||
currentRoute.picksVersion = 0
|
||||
currentRoute.picks = nil
|
||||
currentRoute.providerVersions = nil
|
||||
currentRoute.ruleSets = nil
|
||||
currentRoute.watchSeq = 0
|
||||
}
|
||||
|
||||
func recordRoutes(t *testing.T) *routeRecorder {
|
||||
t.Helper()
|
||||
clearRoute()
|
||||
recorder := &routeRecorder{}
|
||||
previous := publishRoute
|
||||
publishRoute = recorder.record
|
||||
t.Cleanup(func() {
|
||||
clearRoute()
|
||||
publishRoute = previous
|
||||
})
|
||||
return recorder
|
||||
}
|
||||
|
||||
func stampRoute(epoch, picksVersion uint64) {
|
||||
currentRoute.mu.Lock()
|
||||
defer currentRoute.mu.Unlock()
|
||||
currentRoute.epoch = epoch
|
||||
currentRoute.picksVersion = picksVersion
|
||||
}
|
||||
|
||||
func selectorOver(t *testing.T, name string, members ...constant.Proxy) constant.Proxy {
|
||||
t.Helper()
|
||||
health := provider.NewHealthCheck(members, "", 0, 0, true, nil)
|
||||
pd, err := provider.NewCompatibleProvider(name+"-provider", members, health)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCompatibleProvider: %v", err)
|
||||
}
|
||||
group, err := outboundgroup.NewSelector(
|
||||
outboundgroup.GroupCommonOption{Name: name},
|
||||
outboundgroup.SelectorOption{},
|
||||
nil,
|
||||
[]cp.ProxyProvider{pd},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSelector: %v", err)
|
||||
}
|
||||
return adapter.NewProxy(group)
|
||||
}
|
||||
|
||||
func TestHandleChangeProxyReportsWhetherThePickMoved(t *testing.T) {
|
||||
recorder := recordRoutes(t)
|
||||
group := selectorGroup(t, "group", "node-a", "node-b")
|
||||
tunnel.UpdateProxies(map[string]constant.Proxy{"group": group}, nil)
|
||||
t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) })
|
||||
|
||||
state := handleWatchRoute(true, 0)
|
||||
if state.Picks["group"] != "node-a" {
|
||||
t.Fatalf("picks = %v, want the group's first member before any selection", state.Picks)
|
||||
}
|
||||
|
||||
result := handleChangeProxy(&ChangeProxyParams{GroupName: "group", ProxyName: "node-b"})
|
||||
if result.Message != "" || !result.Changed {
|
||||
t.Fatalf("result = %+v, want a silent change", result)
|
||||
}
|
||||
published := recorder.published()
|
||||
if len(published) != 1 {
|
||||
t.Fatalf("published %d route states, want 1", len(published))
|
||||
}
|
||||
if published[0].Picks["group"] != "node-b" || published[0].PicksVersion != state.PicksVersion+1 {
|
||||
t.Errorf("published = %+v, want the new pick under the next version", published[0])
|
||||
}
|
||||
|
||||
result = handleChangeProxy(&ChangeProxyParams{GroupName: "group", ProxyName: "node-b"})
|
||||
if result.Changed {
|
||||
t.Error("re-selecting the current node reported a change")
|
||||
}
|
||||
if len(recorder.published()) != 1 {
|
||||
t.Error("re-selecting the current node published a route state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedGroupSwitchPublishesEveryPick(t *testing.T) {
|
||||
recorder := recordRoutes(t)
|
||||
nested := selectorGroup(t, "Nested", "HK-01", "HK-02")
|
||||
outer := selectorOver(t, "Proxy", nested, namedProxy("JP-01"))
|
||||
tunnel.UpdateProxies(map[string]constant.Proxy{"Proxy": outer, "Nested": nested}, nil)
|
||||
t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) })
|
||||
handleWatchRoute(true, 0)
|
||||
|
||||
handleChangeProxy(&ChangeProxyParams{GroupName: "Nested", ProxyName: "HK-02"})
|
||||
|
||||
published := recorder.published()
|
||||
want := map[string]string{"Proxy": "Nested", "Nested": "HK-02"}
|
||||
if len(published) != 1 || !maps.Equal(published[0].Picks, want) {
|
||||
t.Fatalf("published = %+v, want one state with picks %v", published, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteEpochFollowsStructuralChanges(t *testing.T) {
|
||||
recorder := recordRoutes(t)
|
||||
handleWatchRoute(true, 0)
|
||||
before := routeState().CoreEpoch
|
||||
|
||||
bumpRouteEpoch()
|
||||
if got := routeState().CoreEpoch; got != before+1 {
|
||||
t.Fatalf("epoch = %d after an apply, want %d", got, before+1)
|
||||
}
|
||||
if len(recorder.published()) != 1 {
|
||||
t.Error("an apply did not publish the new epoch")
|
||||
}
|
||||
|
||||
general := &config.General{}
|
||||
general.Mode = tunnel.Rule
|
||||
withCurrentConfig(t, &config.Config{General: general, Controller: &config.Controller{}})
|
||||
global := tunnel.Global
|
||||
if err := updateConfig(&UpdateParams{Mode: &global}); err != nil {
|
||||
t.Fatalf("updateConfig: %v", err)
|
||||
}
|
||||
if got := routeState().CoreEpoch; got != before+2 {
|
||||
t.Errorf("epoch = %d after a mode switch, want %d", got, before+2)
|
||||
}
|
||||
if err := updateConfig(&UpdateParams{Mode: &global}); err != nil {
|
||||
t.Fatalf("updateConfig: %v", err)
|
||||
}
|
||||
if got := routeState().CoreEpoch; got != before+2 {
|
||||
t.Errorf("epoch = %d after an unchanged mode, want it left at %d", got, before+2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderRefreshAdvancesTheEpoch(t *testing.T) {
|
||||
recordRoutes(t)
|
||||
subscription := newCachingProvider("subscription", "node-a")
|
||||
withTunnelProviders(t, map[string]cp.ProxyProvider{"subscription": subscription}, nil)
|
||||
before := handleWatchRoute(true, 0).CoreEpoch
|
||||
|
||||
refreshRoute()
|
||||
if got := routeState().CoreEpoch; got != before {
|
||||
t.Fatalf("epoch = %d after a refresh without a provider change, want %d", got, before)
|
||||
}
|
||||
|
||||
subscription.setProxies("node-b")
|
||||
refreshRoute()
|
||||
if got := routeState().CoreEpoch; got != before+1 {
|
||||
t.Errorf("epoch = %d after the provider swapped its list, want %d", got, before+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleSetContentAdvancesTheEpoch(t *testing.T) {
|
||||
recorder := recordRoutes(t)
|
||||
rp.SetTunnel(tunnel.Tunnel)
|
||||
ruleSet := rp.NewRuleSetProvider(
|
||||
"ruleset", cp.Domain, cp.YamlRule, 0,
|
||||
resource.NewFileVehicle(filepath.Join(t.TempDir(), "ruleset.yaml")),
|
||||
nil, nil, rules.ParseRule,
|
||||
).(*rp.RuleSetProvider)
|
||||
load := func(payload string) {
|
||||
t.Helper()
|
||||
if _, _, err := ruleSet.SideUpdate([]byte(payload)); err != nil {
|
||||
t.Fatalf("SideUpdate: %v", err)
|
||||
}
|
||||
}
|
||||
load("payload:\n - example.com\n")
|
||||
withTunnelProviders(t, nil, map[string]cp.RuleProvider{"ruleset": ruleSet})
|
||||
before := handleWatchRoute(true, 0).CoreEpoch
|
||||
|
||||
load("payload:\n - example.com\n")
|
||||
refreshRoute()
|
||||
if got := routeState().CoreEpoch; got != before {
|
||||
t.Fatalf("epoch = %d after the rule set reloaded the same content, want %d", got, before)
|
||||
}
|
||||
|
||||
load("payload:\n - example.org\n")
|
||||
refreshRoute()
|
||||
if got := routeState().CoreEpoch; got != before+1 {
|
||||
t.Errorf("epoch = %d after the rule set loaded new content, want %d", got, before+1)
|
||||
}
|
||||
if published := recorder.published(); len(published) != 1 || published[0].CoreEpoch != before+1 {
|
||||
t.Errorf("published = %+v, want the new epoch once", published)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeAnswersCarryTheRouteStamp(t *testing.T) {
|
||||
recordRoutes(t)
|
||||
stampRoute(7, 42)
|
||||
withProbeProxies(t, "node-a")
|
||||
server := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("ip=203.0.113.7"))
|
||||
})
|
||||
|
||||
answer := handleOutboundIp(&OutboundIpParams{ProxyName: "node-a", Urls: []string{server.URL}, Timeout: 4000})
|
||||
if answer.CoreEpoch != 7 || answer.PicksVersion != 42 {
|
||||
t.Errorf("outbound ip stamp = %d/%d, want 7/42", answer.CoreEpoch, answer.PicksVersion)
|
||||
}
|
||||
|
||||
failed := handleOutboundIp(&OutboundIpParams{ProxyName: "node-a", Timeout: 1000})
|
||||
if failed.Error == "" || failed.CoreEpoch != 7 || failed.PicksVersion != 42 {
|
||||
t.Errorf("failed outbound ip = %+v, want an error stamped 7/42", failed)
|
||||
}
|
||||
|
||||
withServiceCheckers(t, stubChecker("alpha", ServiceCheckItem{Status: serviceAvailable}))
|
||||
items := handleServiceCheck(&ServiceCheckParams{Timeout: 1000})
|
||||
if items[0].CoreEpoch != 7 || items[0].PicksVersion != 42 {
|
||||
t.Errorf("service check stamp = %d/%d, want 7/42", items[0].CoreEpoch, items[0].PicksVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchRoutePollsOnlyWhileWatched(t *testing.T) {
|
||||
recordRoutes(t)
|
||||
|
||||
handleWatchRoute(true, 0)
|
||||
currentRoute.mu.Lock()
|
||||
polling := currentRoute.stopPoll != nil
|
||||
currentRoute.mu.Unlock()
|
||||
if !polling {
|
||||
t.Fatal("watching the route did not start the poll")
|
||||
}
|
||||
|
||||
handleWatchRoute(false, 0)
|
||||
currentRoute.mu.Lock()
|
||||
polling = currentRoute.stopPoll != nil
|
||||
watched := currentRoute.watched
|
||||
currentRoute.mu.Unlock()
|
||||
if polling || watched {
|
||||
t.Error("releasing the route left the poll running")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchRouteIgnoresAnOlderIntent(t *testing.T) {
|
||||
recordRoutes(t)
|
||||
|
||||
handleWatchRoute(false, 2)
|
||||
handleWatchRoute(true, 1)
|
||||
currentRoute.mu.Lock()
|
||||
polling := currentRoute.stopPoll != nil
|
||||
watched := currentRoute.watched
|
||||
currentRoute.mu.Unlock()
|
||||
if polling || watched {
|
||||
t.Error("a watch overtaken by a later release restarted the poll")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopRouteWatchOutranksEarlierWatches(t *testing.T) {
|
||||
recordRoutes(t)
|
||||
previous := methodCallSeq.Load()
|
||||
t.Cleanup(func() { methodCallSeq.Store(previous) })
|
||||
methodCallSeq.Store(previous + 5)
|
||||
|
||||
stopRouteWatch()
|
||||
for _, seq := range []uint64{previous + 4, previous + 5} {
|
||||
handleWatchRoute(true, seq)
|
||||
currentRoute.mu.Lock()
|
||||
polling := currentRoute.stopPoll != nil
|
||||
currentRoute.mu.Unlock()
|
||||
if polling {
|
||||
t.Fatalf("watch %d, dispatched before the stop, restarted the poll", seq-previous)
|
||||
}
|
||||
}
|
||||
|
||||
handleWatchRoute(true, methodCallSeq.Add(1))
|
||||
currentRoute.mu.Lock()
|
||||
polling := currentRoute.stopPoll != nil
|
||||
currentRoute.mu.Unlock()
|
||||
handleWatchRoute(false, methodCallSeq.Add(1))
|
||||
if !polling {
|
||||
t.Error("a watch issued after the stop was ignored")
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,11 @@ func deliverEvent(data []byte) {
|
||||
send(data)
|
||||
}
|
||||
|
||||
// The desktop core exits with its host connection, so it is always heard.
|
||||
func hasEventListener() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func writeFrame(w io.Writer, data []byte) (int, error) {
|
||||
if len(data) > maxIPCFrameSize {
|
||||
return 0, fmt.Errorf("IPC frame exceeds %d bytes", maxIPCFrameSize)
|
||||
@@ -212,6 +217,6 @@ func serve(dialed ipcConn) {
|
||||
continue
|
||||
}
|
||||
|
||||
go handleMethodCall(call, newMethodResponse(call.ID, nil))
|
||||
dispatchMethodCall(call, newMethodResponse(call.ID, nil))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/metacubex/http"
|
||||
)
|
||||
|
||||
const (
|
||||
// A sweep runs a few checks at a time so it cannot take every probe slot.
|
||||
// It therefore runs in waves, and a rule like Disney+ spends several
|
||||
// requests in sequence, so the run gets a multiple of the request budget.
|
||||
serviceCheckConcurrency = 4
|
||||
serviceSweepBudgetFactor = 6
|
||||
|
||||
// Region markers sit deep inside these pages; endpoints that answer with a
|
||||
// few lines get the small cap.
|
||||
serviceHtmlMaxBody = 512 * 1024
|
||||
serviceSmallMaxBody = 16 * 1024
|
||||
|
||||
// Gemini's region sits past 800 KiB, Prime Video's at times past 4 MiB.
|
||||
serviceScanMaxBody = 8 * 1024 * 1024
|
||||
)
|
||||
|
||||
// Statuses a check reports. Dart maps each to a localized label, so these
|
||||
// strings are part of the contract and cannot be reworded on one side alone.
|
||||
const (
|
||||
serviceAvailable = "available"
|
||||
serviceUnavailable = "unavailable"
|
||||
serviceRestricted = "restricted"
|
||||
serviceDisallowedIsp = "disallowed-isp"
|
||||
serviceBlocked = "blocked"
|
||||
serviceUnsupportedRegion = "unsupported-region"
|
||||
serviceOriginalsOnly = "originals-only"
|
||||
serviceComingSoon = "coming-soon"
|
||||
serviceTimeout = "timeout"
|
||||
serviceFailed = "failed"
|
||||
)
|
||||
|
||||
// An empty Names runs every check.
|
||||
type ServiceCheckParams struct {
|
||||
ProxyName string `json:"proxy-name"`
|
||||
Names []string `json:"names"`
|
||||
Timeout int64 `json:"timeout"`
|
||||
}
|
||||
|
||||
// runServiceCheck stamps Name; a blank Status means a rule has yet to judge it.
|
||||
type ServiceCheckItem struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Delay int64 `json:"delay,omitempty"`
|
||||
Chains []string `json:"chains,omitempty"`
|
||||
CheckedAt int64 `json:"checked-at"`
|
||||
CoreEpoch uint64 `json:"core-epoch"`
|
||||
PicksVersion uint64 `json:"picks-version"`
|
||||
}
|
||||
|
||||
var (
|
||||
serviceClaimsMu sync.Mutex
|
||||
serviceClaims = map[string]*context.CancelFunc{}
|
||||
)
|
||||
|
||||
type serviceEnv struct {
|
||||
ctx context.Context
|
||||
proxyName string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func (e serviceEnv) get(url string, maxBody int64) *ProbeResult {
|
||||
return e.send(probeRequest{method: http.MethodGet, url: url, maxBody: maxBody})
|
||||
}
|
||||
|
||||
func (e serviceEnv) scan(url string, until func(tail string) bool) *ProbeResult {
|
||||
return e.send(probeRequest{method: http.MethodGet, url: url, maxBody: serviceScanMaxBody, until: until})
|
||||
}
|
||||
|
||||
func (e serviceEnv) send(req probeRequest) *ProbeResult {
|
||||
req.proxyName = e.proxyName
|
||||
req.timeout = e.timeout
|
||||
headers := map[string]string{"User-Agent": browserUserAgent}
|
||||
for name, value := range req.headers {
|
||||
headers[name] = value
|
||||
}
|
||||
req.headers = headers
|
||||
return runProbe(e.ctx, req)
|
||||
}
|
||||
|
||||
type serviceChecker struct {
|
||||
name string
|
||||
check func(serviceEnv) ServiceCheckItem
|
||||
}
|
||||
|
||||
var serviceCheckers = []serviceChecker{
|
||||
{name: "google", check: checkReachable("https://www.google.com/generate_204")},
|
||||
{name: "github", check: checkReachable("https://github.com/")},
|
||||
{name: "youtube", check: checkYouTubePremium},
|
||||
{name: "chatgpt", check: checkChatGpt},
|
||||
{name: "claude", check: checkClaude},
|
||||
{name: "gemini", check: checkGemini},
|
||||
{name: "netflix", check: checkNetflix},
|
||||
{name: "disney-plus", check: checkDisneyPlus},
|
||||
{name: "prime-video", check: checkPrimeVideo},
|
||||
{name: "spotify", check: checkSpotify},
|
||||
{name: "tiktok", check: checkTikTok},
|
||||
{name: "bilibili", check: checkBilibili},
|
||||
}
|
||||
|
||||
func handleServiceCheck(params *ServiceCheckParams) []ServiceCheckItem {
|
||||
wanted := selectedCheckers(params.Names)
|
||||
items := make([]ServiceCheckItem, len(wanted))
|
||||
if len(wanted) == 0 {
|
||||
return items
|
||||
}
|
||||
|
||||
timeout := probeTimeout(params.Timeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout*serviceSweepBudgetFactor)
|
||||
defer cancel()
|
||||
|
||||
type slot struct {
|
||||
index int
|
||||
checker serviceChecker
|
||||
ctx context.Context
|
||||
}
|
||||
queue := make(chan slot)
|
||||
done := make(chan struct{})
|
||||
|
||||
workers := min(serviceCheckConcurrency, len(wanted))
|
||||
for worker := 0; worker < workers; worker++ {
|
||||
go func() {
|
||||
defer func() { done <- struct{}{} }()
|
||||
for job := range queue {
|
||||
items[job.index] = runServiceCheck(job.ctx, job.checker, params.ProxyName, timeout)
|
||||
}
|
||||
}()
|
||||
}
|
||||
contexts, release := claimServices(ctx, wanted)
|
||||
defer release()
|
||||
for index, checker := range wanted {
|
||||
queue <- slot{index: index, checker: checker, ctx: contexts[index]}
|
||||
}
|
||||
close(queue)
|
||||
for worker := 0; worker < workers; worker++ {
|
||||
<-done
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// A misbehaving rule must not take the sweep down with it.
|
||||
func runServiceCheck(ctx context.Context, checker serviceChecker, proxyName string, timeout time.Duration) (item ServiceCheckItem) {
|
||||
epoch, picksVersion := routeStamp()
|
||||
item = ServiceCheckItem{Status: serviceFailed}
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
item = ServiceCheckItem{Status: serviceFailed}
|
||||
}
|
||||
item.Name = checker.name
|
||||
item.CheckedAt = time.Now().UnixMilli()
|
||||
item.CoreEpoch = epoch
|
||||
item.PicksVersion = picksVersion
|
||||
}()
|
||||
if ctx.Err() != nil {
|
||||
return item
|
||||
}
|
||||
env := serviceEnv{ctx: ctx, proxyName: proxyName, timeout: timeout}
|
||||
item = checker.check(env)
|
||||
if item.Status == serviceTimeout && hasBudget(ctx, timeout) {
|
||||
item = checker.check(env)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// The host asks for a service again only once the older answer can no longer
|
||||
// be used, so a newer check cancels the older one and frees its probe slots.
|
||||
func claimServices(parent context.Context, checkers []serviceChecker) ([]context.Context, func()) {
|
||||
contexts := make([]context.Context, len(checkers))
|
||||
claimed := make(map[string]context.Context, len(checkers))
|
||||
var releases []func()
|
||||
for index, checker := range checkers {
|
||||
ctx, ok := claimed[checker.name]
|
||||
if !ok {
|
||||
var release func()
|
||||
ctx, release = claimService(parent, checker.name)
|
||||
claimed[checker.name] = ctx
|
||||
releases = append(releases, release)
|
||||
}
|
||||
contexts[index] = ctx
|
||||
}
|
||||
return contexts, func() {
|
||||
for _, release := range releases {
|
||||
release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func claimService(parent context.Context, name string) (context.Context, func()) {
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
claim := &cancel
|
||||
serviceClaimsMu.Lock()
|
||||
if previous := serviceClaims[name]; previous != nil {
|
||||
(*previous)()
|
||||
}
|
||||
serviceClaims[name] = claim
|
||||
serviceClaimsMu.Unlock()
|
||||
return ctx, func() {
|
||||
cancel()
|
||||
serviceClaimsMu.Lock()
|
||||
if serviceClaims[name] == claim {
|
||||
delete(serviceClaims, name)
|
||||
}
|
||||
serviceClaimsMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func hasBudget(ctx context.Context, timeout time.Duration) bool {
|
||||
deadline, ok := ctx.Deadline()
|
||||
return !ok || time.Until(deadline) >= timeout
|
||||
}
|
||||
|
||||
func selectedCheckers(names []string) []serviceChecker {
|
||||
if len(names) == 0 {
|
||||
return serviceCheckers
|
||||
}
|
||||
wanted := make([]serviceChecker, 0, len(names))
|
||||
for _, name := range names {
|
||||
for _, checker := range serviceCheckers {
|
||||
if checker.name == name {
|
||||
wanted = append(wanted, checker)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return wanted
|
||||
}
|
||||
|
||||
// checkReachable is the rule for a target with no unlock semantics of its own.
|
||||
func checkReachable(url string) func(serviceEnv) ServiceCheckItem {
|
||||
return func(env serviceEnv) ServiceCheckItem {
|
||||
result := env.get(url, 0)
|
||||
item := itemFrom(result)
|
||||
if item.Status != "" {
|
||||
return item
|
||||
}
|
||||
item.Status = statusFromCode(result.StatusCode)
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
func itemFrom(result *ProbeResult) ServiceCheckItem {
|
||||
if result == nil {
|
||||
return ServiceCheckItem{Status: serviceFailed}
|
||||
}
|
||||
item := ServiceCheckItem{Delay: result.Delay, Chains: result.Chains}
|
||||
switch result.Error {
|
||||
case "":
|
||||
case probeErrorTimeout:
|
||||
item.Status = serviceTimeout
|
||||
item.Delay = 0
|
||||
default:
|
||||
item.Status = serviceFailed
|
||||
item.Delay = 0
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func statusFromCode(code int) string {
|
||||
switch {
|
||||
case code >= 200 && code < 300:
|
||||
return serviceAvailable
|
||||
case code == http.StatusUnauthorized,
|
||||
code == http.StatusForbidden,
|
||||
code == http.StatusTooManyRequests,
|
||||
code == http.StatusUnavailableForLegalReasons:
|
||||
return serviceRestricted
|
||||
default:
|
||||
return serviceUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
func answered(item ServiceCheckItem, result *ProbeResult) bool {
|
||||
return item.Status == "" && result != nil
|
||||
}
|
||||
|
||||
func bodyContains(result *ProbeResult, needles ...string) bool {
|
||||
body := strings.ToLower(result.Body)
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(body, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func traceValue(body string, key string) string {
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
if rest, found := strings.CutPrefix(line, key+"="); found {
|
||||
return strings.TrimSpace(rest)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func withServiceCheckers(t *testing.T, checkers ...serviceChecker) {
|
||||
t.Helper()
|
||||
previous := serviceCheckers
|
||||
serviceCheckers = checkers
|
||||
t.Cleanup(func() { serviceCheckers = previous })
|
||||
}
|
||||
|
||||
func stubChecker(name string, item ServiceCheckItem) serviceChecker {
|
||||
return serviceChecker{name: name, check: func(serviceEnv) ServiceCheckItem { return item }}
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckRunsEveryCheckerByDefault(t *testing.T) {
|
||||
withServiceCheckers(t,
|
||||
stubChecker("alpha", ServiceCheckItem{Status: serviceAvailable, Region: "US"}),
|
||||
stubChecker("beta", ServiceCheckItem{Status: serviceUnsupportedRegion}),
|
||||
)
|
||||
|
||||
items := handleServiceCheck(&ServiceCheckParams{Timeout: 1000})
|
||||
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items = %d, want 2", len(items))
|
||||
}
|
||||
if items[0].Name != "alpha" || items[0].Status != serviceAvailable || items[0].Region != "US" {
|
||||
t.Errorf("items[0] = %+v", items[0])
|
||||
}
|
||||
if items[1].Name != "beta" || items[1].Status != serviceUnsupportedRegion {
|
||||
t.Errorf("items[1] = %+v", items[1])
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.CheckedAt == 0 {
|
||||
t.Errorf("%s has no check time", item.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckStampsTheRegisteredName(t *testing.T) {
|
||||
withServiceCheckers(t,
|
||||
stubChecker("alpha", ServiceCheckItem{Name: "stale", Status: serviceAvailable}),
|
||||
)
|
||||
|
||||
items := handleServiceCheck(&ServiceCheckParams{Timeout: 1000})
|
||||
|
||||
if items[0].Name != "alpha" {
|
||||
t.Errorf("name = %q, want the registry's rather than the rule's", items[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckKeepsTheRequestedOrder(t *testing.T) {
|
||||
withServiceCheckers(t,
|
||||
stubChecker("alpha", ServiceCheckItem{Status: serviceAvailable}),
|
||||
stubChecker("beta", ServiceCheckItem{Status: serviceAvailable}),
|
||||
stubChecker("gamma", ServiceCheckItem{Status: serviceAvailable}),
|
||||
)
|
||||
|
||||
items := handleServiceCheck(&ServiceCheckParams{Names: []string{"gamma", "alpha"}, Timeout: 1000})
|
||||
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items = %d, want 2", len(items))
|
||||
}
|
||||
if items[0].Name != "gamma" || items[1].Name != "alpha" {
|
||||
t.Errorf("names = %q, %q, want gamma, alpha", items[0].Name, items[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckIgnoresAnUnknownName(t *testing.T) {
|
||||
withServiceCheckers(t, stubChecker("alpha", ServiceCheckItem{Status: serviceAvailable}))
|
||||
|
||||
items := handleServiceCheck(&ServiceCheckParams{Names: []string{"nope"}, Timeout: 1000})
|
||||
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("items = %d, want none", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckSurvivesAPanickingRule(t *testing.T) {
|
||||
withServiceCheckers(t,
|
||||
serviceChecker{name: "boom", check: func(serviceEnv) ServiceCheckItem { panic("rule blew up") }},
|
||||
stubChecker("alpha", ServiceCheckItem{Status: serviceAvailable}),
|
||||
)
|
||||
|
||||
items := handleServiceCheck(&ServiceCheckParams{Timeout: 1000})
|
||||
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items = %d, want 2", len(items))
|
||||
}
|
||||
if items[0].Status != serviceFailed {
|
||||
t.Errorf("panicking rule reported %q, want %q", items[0].Status, serviceFailed)
|
||||
}
|
||||
if items[1].Status != serviceAvailable {
|
||||
t.Errorf("the sibling rule reported %q, want it unaffected", items[1].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckBoundsConcurrency(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var running, peak int
|
||||
var total atomic.Int32
|
||||
blocking := func(name string) serviceChecker {
|
||||
return serviceChecker{name: name, check: func(serviceEnv) ServiceCheckItem {
|
||||
mu.Lock()
|
||||
running++
|
||||
if running > peak {
|
||||
peak = running
|
||||
}
|
||||
mu.Unlock()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
mu.Lock()
|
||||
running--
|
||||
mu.Unlock()
|
||||
total.Add(1)
|
||||
return ServiceCheckItem{Status: serviceAvailable}
|
||||
}}
|
||||
}
|
||||
checkers := make([]serviceChecker, 0, 12)
|
||||
for index := 0; index < 12; index++ {
|
||||
checkers = append(checkers, blocking("x"))
|
||||
}
|
||||
withServiceCheckers(t, checkers...)
|
||||
|
||||
handleServiceCheck(&ServiceCheckParams{Timeout: 5000})
|
||||
|
||||
if got := total.Load(); got != 12 {
|
||||
t.Errorf("ran %d checks, want 12", got)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if peak > serviceCheckConcurrency {
|
||||
t.Errorf("peak concurrency = %d, want at most %d", peak, serviceCheckConcurrency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckRetriesATimeoutOnce(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
withServiceCheckers(t, serviceChecker{name: "flaky", check: func(serviceEnv) ServiceCheckItem {
|
||||
if calls.Add(1) == 1 {
|
||||
return ServiceCheckItem{Status: serviceTimeout}
|
||||
}
|
||||
return ServiceCheckItem{Status: serviceAvailable}
|
||||
}})
|
||||
|
||||
items := handleServiceCheck(&ServiceCheckParams{Timeout: 1000})
|
||||
|
||||
if items[0].Status != serviceAvailable {
|
||||
t.Errorf("status = %q, want the retry's answer", items[0].Status)
|
||||
}
|
||||
if got := calls.Load(); got != 2 {
|
||||
t.Errorf("ran %d times, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckReportsARepeatedTimeout(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
withServiceCheckers(t, serviceChecker{name: "down", check: func(serviceEnv) ServiceCheckItem {
|
||||
calls.Add(1)
|
||||
return ServiceCheckItem{Status: serviceTimeout}
|
||||
}})
|
||||
|
||||
items := handleServiceCheck(&ServiceCheckParams{Timeout: 1000})
|
||||
|
||||
if items[0].Status != serviceTimeout {
|
||||
t.Errorf("status = %q, want %q", items[0].Status, serviceTimeout)
|
||||
}
|
||||
if got := calls.Load(); got != 2 {
|
||||
t.Errorf("ran %d times, want a single retry", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckCancelsTheOlderCheckOfTheSameService(t *testing.T) {
|
||||
started := make(chan struct{}, 2)
|
||||
var calls atomic.Int32
|
||||
withServiceCheckers(t,
|
||||
serviceChecker{name: "alpha", check: func(env serviceEnv) ServiceCheckItem {
|
||||
if calls.Add(1) > 1 {
|
||||
return ServiceCheckItem{Status: serviceAvailable}
|
||||
}
|
||||
started <- struct{}{}
|
||||
<-env.ctx.Done()
|
||||
return ServiceCheckItem{Status: serviceFailed}
|
||||
}},
|
||||
serviceChecker{name: "beta", check: func(env serviceEnv) ServiceCheckItem {
|
||||
started <- struct{}{}
|
||||
select {
|
||||
case <-env.ctx.Done():
|
||||
return ServiceCheckItem{Status: serviceFailed}
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
return ServiceCheckItem{Status: serviceAvailable}
|
||||
}
|
||||
}},
|
||||
)
|
||||
|
||||
older := make(chan []ServiceCheckItem)
|
||||
go func() { older <- handleServiceCheck(&ServiceCheckParams{Timeout: 5000}) }()
|
||||
<-started
|
||||
<-started
|
||||
|
||||
newer := handleServiceCheck(&ServiceCheckParams{Names: []string{"alpha"}, Timeout: 5000})
|
||||
|
||||
if newer[0].Status != serviceAvailable {
|
||||
t.Errorf("newer alpha = %q, want %q", newer[0].Status, serviceAvailable)
|
||||
}
|
||||
select {
|
||||
case items := <-older:
|
||||
if items[0].Status != serviceFailed {
|
||||
t.Errorf("older alpha = %q, want it cancelled", items[0].Status)
|
||||
}
|
||||
if items[1].Status != serviceAvailable {
|
||||
t.Errorf("older beta = %q, want it left running", items[1].Status)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the older sweep kept running after its service was asked again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusFromCode(t *testing.T) {
|
||||
cases := map[int]string{
|
||||
http.StatusOK: serviceAvailable,
|
||||
http.StatusNoContent: serviceAvailable,
|
||||
http.StatusUnauthorized: serviceRestricted,
|
||||
http.StatusForbidden: serviceRestricted,
|
||||
http.StatusTooManyRequests: serviceRestricted,
|
||||
http.StatusUnavailableForLegalReasons: serviceRestricted,
|
||||
http.StatusNotFound: serviceUnavailable,
|
||||
http.StatusInternalServerError: serviceUnavailable,
|
||||
}
|
||||
for code, want := range cases {
|
||||
if got := statusFromCode(code); got != want {
|
||||
t.Errorf("statusFromCode(%d) = %q, want %q", code, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemFromMapsTransportErrors(t *testing.T) {
|
||||
timedOut := itemFrom(&ProbeResult{Error: probeErrorTimeout, Delay: 900})
|
||||
if timedOut.Status != serviceTimeout {
|
||||
t.Errorf("status = %q, want %q", timedOut.Status, serviceTimeout)
|
||||
}
|
||||
if timedOut.Delay != 0 {
|
||||
t.Errorf("delay = %d, want it dropped for a timeout", timedOut.Delay)
|
||||
}
|
||||
if missing := itemFrom(nil); missing.Status != serviceFailed {
|
||||
t.Errorf("status = %q, want %q when the probe got no slot", missing.Status, serviceFailed)
|
||||
}
|
||||
answered := itemFrom(&ProbeResult{StatusCode: http.StatusOK, Delay: 120})
|
||||
if answered.Status != "" {
|
||||
t.Errorf("status = %q, want it left for the rule to judge", answered.Status)
|
||||
}
|
||||
if answered.Delay != 120 {
|
||||
t.Errorf("delay = %d, want 120", answered.Delay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceValue(t *testing.T) {
|
||||
body := "fl=abc\nip=203.0.113.7\nloc=JP\nwarp=off\n"
|
||||
if got := traceValue(body, "loc"); got != "JP" {
|
||||
t.Errorf("loc = %q, want JP", got)
|
||||
}
|
||||
if got := traceValue(body, "colo"); got != "" {
|
||||
t.Errorf("colo = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/metacubex/http"
|
||||
)
|
||||
|
||||
// disneyAuthHeader is the public browser client token Disney+ hands to any web
|
||||
// visitor; the device and token calls below are rejected without it.
|
||||
const disneyAuthHeader = "Bearer ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84"
|
||||
|
||||
var (
|
||||
youTubeRegionPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)id=["']country-code["'][^>]*>\s*([A-Za-z]{2,3})\s*<`),
|
||||
regexp.MustCompile(`"GL"\s*:\s*"([A-Za-z]{2})"`),
|
||||
regexp.MustCompile(`"countryCode"\s*:\s*"([A-Za-z]{2})"`),
|
||||
regexp.MustCompile(`"country_code"\s*:\s*"([A-Za-z]{2})"`),
|
||||
}
|
||||
tikTokRegionPattern = regexp.MustCompile(`"webapp\.app-context"\s*:\s*\{[^{}]*?"region"\s*:\s*"([^"]*)"`)
|
||||
primeRegionPattern = regexp.MustCompile(`"currentTerritory":"([^"]+)"`)
|
||||
disneyAssertionPattern = regexp.MustCompile(`"assertion"\s*:\s*"([^"]+)"`)
|
||||
disneyRefreshPattern = regexp.MustCompile(`"refresh_token"\s*:\s*"([^"]+)"`)
|
||||
disneyCountryPattern = regexp.MustCompile(`"countryCode"\s*:\s*"([^"]+)"`)
|
||||
disneySupportedPattern = regexp.MustCompile(`"inSupportedLocation"\s*:\s*(false|true)`)
|
||||
spotifyCountryPattern = regexp.MustCompile(`"countryCode"\s*:\s*"([^"]+)"`)
|
||||
geminiRegionMarker = `,2,1,200,"`
|
||||
claudeBlockedRegions = []string{"AF", "BY", "CN", "CU", "HK", "IR", "KP", "MO", "RU", "SY"}
|
||||
geminiBlockedRegions = []string{"CHN", "RUS", "BLR", "CUB", "IRN", "PRK", "SYR", "HKG", "MAC"}
|
||||
)
|
||||
|
||||
func firstSubmatch(pattern *regexp.Regexp, body string) string {
|
||||
match := pattern.FindStringSubmatch(body)
|
||||
if len(match) < 2 {
|
||||
return ""
|
||||
}
|
||||
return match[1]
|
||||
}
|
||||
|
||||
func checkClaude(env serviceEnv) ServiceCheckItem {
|
||||
result := env.get("https://claude.ai/cdn-cgi/trace", serviceSmallMaxBody)
|
||||
item := itemFrom(result)
|
||||
if !answered(item, result) {
|
||||
return item
|
||||
}
|
||||
region := normalizeRegion(traceValue(result.Body, "loc"))
|
||||
if region == "" {
|
||||
item.Status = serviceFailed
|
||||
return item
|
||||
}
|
||||
item.Region = region
|
||||
item.Status = serviceAvailable
|
||||
if slices.Contains(claudeBlockedRegions, region) {
|
||||
item.Status = serviceUnsupportedRegion
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func checkGemini(env serviceEnv) ServiceCheckItem {
|
||||
result := env.scan("https://gemini.google.com", func(tail string) bool {
|
||||
index := strings.Index(tail, geminiRegionMarker)
|
||||
return index >= 0 && index+len(geminiRegionMarker)+3 <= len(tail)
|
||||
})
|
||||
item := itemFrom(result)
|
||||
if !answered(item, result) {
|
||||
return item
|
||||
}
|
||||
index := strings.Index(result.Body, geminiRegionMarker)
|
||||
if index < 0 || index+len(geminiRegionMarker)+3 > len(result.Body) {
|
||||
item.Status = serviceFailed
|
||||
return item
|
||||
}
|
||||
start := index + len(geminiRegionMarker)
|
||||
code := result.Body[start : start+3]
|
||||
if !isAsciiUpper(code) {
|
||||
item.Status = serviceFailed
|
||||
return item
|
||||
}
|
||||
item.Region = normalizeRegion(code)
|
||||
item.Status = serviceAvailable
|
||||
if slices.Contains(geminiBlockedRegions, code) {
|
||||
item.Status = serviceUnsupportedRegion
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// checkChatGpt folds Verge's separate iOS and Web results into one entry: the
|
||||
// card shows a single row per service, and a split would put two identical
|
||||
// OpenAI icons next to each other.
|
||||
func checkChatGpt(env serviceEnv) ServiceCheckItem {
|
||||
ios := env.get("https://ios.chat.openai.com/", serviceSmallMaxBody)
|
||||
item := itemFrom(ios)
|
||||
if answered(item, ios) {
|
||||
item.Status = chatGptIosStatus(ios)
|
||||
}
|
||||
|
||||
trace := env.get("https://chat.openai.com/cdn-cgi/trace", serviceSmallMaxBody)
|
||||
if traceItem := itemFrom(trace); answered(traceItem, trace) {
|
||||
item.Region = normalizeRegion(traceValue(trace.Body, "loc"))
|
||||
}
|
||||
|
||||
web := env.get("https://api.openai.com/compliance/cookie_requirements", serviceSmallMaxBody)
|
||||
if webItem := itemFrom(web); answered(webItem, web) &&
|
||||
bodyContains(web, "unsupported_country") {
|
||||
item.Status = serviceUnsupportedRegion
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func chatGptIosStatus(result *ProbeResult) string {
|
||||
switch {
|
||||
case bodyContains(result, "you may be connected to a disallowed isp"):
|
||||
return serviceDisallowedIsp
|
||||
case bodyContains(result, "sorry, you have been blocked"):
|
||||
return serviceBlocked
|
||||
case bodyContains(result, "request is not allowed. please try again later."):
|
||||
// Being turned away as an API client is what a reachable region looks
|
||||
// like here; anything else means the request never got that far.
|
||||
return serviceAvailable
|
||||
default:
|
||||
return serviceFailed
|
||||
}
|
||||
}
|
||||
|
||||
func checkYouTubePremium(env serviceEnv) ServiceCheckItem {
|
||||
result := env.get("https://www.youtube.com/premium?hl=en", serviceHtmlMaxBody)
|
||||
item := itemFrom(result)
|
||||
if !answered(item, result) {
|
||||
return item
|
||||
}
|
||||
for _, pattern := range youTubeRegionPatterns {
|
||||
if code := firstSubmatch(pattern, result.Body); code != "" {
|
||||
item.Region = normalizeRegion(code)
|
||||
break
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case bodyContains(result,
|
||||
"youtube premium is not available in your country",
|
||||
"premium is not available in your country",
|
||||
"premium is not available in your region"):
|
||||
item.Status = serviceUnsupportedRegion
|
||||
case result.StatusCode >= 200 && result.StatusCode < 300 &&
|
||||
bodyContains(result, "youtube premium", "ad-free", `"browseid":"spunlimited"`):
|
||||
item.Status = serviceAvailable
|
||||
default:
|
||||
item.Status = statusFromCode(result.StatusCode)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func checkSpotify(env serviceEnv) ServiceCheckItem {
|
||||
result := env.get(
|
||||
"https://www.spotify.com/api/content/v1/country-selector?platform=web&format=json",
|
||||
serviceSmallMaxBody,
|
||||
)
|
||||
item := itemFrom(result)
|
||||
if !answered(item, result) {
|
||||
return item
|
||||
}
|
||||
item.Region = normalizeRegion(spotifyRegion(result))
|
||||
switch {
|
||||
case result.StatusCode == http.StatusForbidden ||
|
||||
result.StatusCode == http.StatusUnavailableForLegalReasons:
|
||||
item.Status = serviceUnsupportedRegion
|
||||
case result.StatusCode < 200 || result.StatusCode >= 300:
|
||||
item.Status = statusFromCode(result.StatusCode)
|
||||
case bodyContains(result, "not available in your country"):
|
||||
item.Status = serviceUnsupportedRegion
|
||||
default:
|
||||
item.Status = serviceAvailable
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// spotifyRegion prefers the country Spotify redirected the request into, and
|
||||
// falls back to the one named in the payload.
|
||||
func spotifyRegion(result *ProbeResult) string {
|
||||
if final, err := url.Parse(result.Url); err == nil {
|
||||
segments := strings.Split(strings.Trim(final.Path, "/"), "/")
|
||||
if first := segments[0]; first != "" && first != "api" {
|
||||
return strings.Split(first, "-")[0]
|
||||
}
|
||||
}
|
||||
return firstSubmatch(spotifyCountryPattern, result.Body)
|
||||
}
|
||||
|
||||
// TikTok is not behind Cloudflare: /cdn-cgi/trace redirects to a 404 page.
|
||||
func checkTikTok(env serviceEnv) ServiceCheckItem {
|
||||
result := env.scan("https://www.tiktok.com/", tikTokRegionPattern.MatchString)
|
||||
item := itemFrom(result)
|
||||
if !answered(item, result) {
|
||||
return item
|
||||
}
|
||||
item.Status = tikTokStatus(result)
|
||||
item.Region = tikTokRegion(result.Body)
|
||||
return item
|
||||
}
|
||||
|
||||
func tikTokStatus(result *ProbeResult) string {
|
||||
switch {
|
||||
case result.StatusCode == http.StatusForbidden ||
|
||||
result.StatusCode == http.StatusUnavailableForLegalReasons:
|
||||
return serviceUnsupportedRegion
|
||||
case result.StatusCode < 200 || result.StatusCode >= 300:
|
||||
return serviceFailed
|
||||
case bodyContains(result, "access denied", "not available in your region", "tiktok is not available"):
|
||||
return serviceUnsupportedRegion
|
||||
default:
|
||||
return serviceAvailable
|
||||
}
|
||||
}
|
||||
|
||||
// The page names its serving cluster "region" too ("EU-TTP2"); the visitor's
|
||||
// country is the one in the web app's context.
|
||||
func tikTokRegion(body string) string {
|
||||
return normalizeRegion(firstSubmatch(tikTokRegionPattern, body))
|
||||
}
|
||||
|
||||
func checkBilibili(env serviceEnv) ServiceCheckItem {
|
||||
const url = "https://api.bilibili.com/pgc/player/web/playurl?avid=18281381&cid=29892777&qn=0&type=&otype=json&ep_id=183799&fourk=1&fnver=0&fnval=16&module=bangumi"
|
||||
result := env.get(url, serviceSmallMaxBody)
|
||||
item := itemFrom(result)
|
||||
if !answered(item, result) {
|
||||
return item
|
||||
}
|
||||
var payload struct {
|
||||
Code int `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(result.Body), &payload); err != nil {
|
||||
item.Status = serviceFailed
|
||||
return item
|
||||
}
|
||||
switch payload.Code {
|
||||
case 0:
|
||||
item.Status = serviceAvailable
|
||||
case -10403:
|
||||
item.Status = serviceUnsupportedRegion
|
||||
default:
|
||||
item.Status = serviceFailed
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func checkPrimeVideo(env serviceEnv) ServiceCheckItem {
|
||||
result := env.scan("https://www.primevideo.com", func(tail string) bool {
|
||||
return strings.Contains(tail, "isServiceRestricted") || primeRegionPattern.MatchString(tail)
|
||||
})
|
||||
item := itemFrom(result)
|
||||
if !answered(item, result) {
|
||||
return item
|
||||
}
|
||||
if strings.Contains(result.Body, "isServiceRestricted") {
|
||||
item.Status = serviceUnsupportedRegion
|
||||
return item
|
||||
}
|
||||
region := firstSubmatch(primeRegionPattern, result.Body)
|
||||
if region == "" {
|
||||
item.Status = serviceFailed
|
||||
return item
|
||||
}
|
||||
item.Region = normalizeRegion(region)
|
||||
item.Status = serviceAvailable
|
||||
return item
|
||||
}
|
||||
|
||||
func checkNetflix(env serviceEnv) ServiceCheckItem {
|
||||
if item := netflixFromCdn(env); item.Status == serviceAvailable || item.Status == serviceBlocked {
|
||||
return item
|
||||
}
|
||||
|
||||
first := env.get("https://www.netflix.com/title/81280792", 0)
|
||||
firstItem := itemFrom(first)
|
||||
if !answered(firstItem, first) {
|
||||
return firstItem
|
||||
}
|
||||
second := env.get("https://www.netflix.com/title/70143836", 0)
|
||||
secondItem := itemFrom(second)
|
||||
if !answered(secondItem, second) {
|
||||
return secondItem
|
||||
}
|
||||
|
||||
item := firstItem
|
||||
switch {
|
||||
case first.StatusCode == http.StatusNotFound && second.StatusCode == http.StatusNotFound:
|
||||
item.Status = serviceOriginalsOnly
|
||||
case first.StatusCode == http.StatusForbidden || second.StatusCode == http.StatusForbidden:
|
||||
item.Status = serviceUnsupportedRegion
|
||||
case netflixServed(first.StatusCode) || netflixServed(second.StatusCode):
|
||||
item.Status = serviceAvailable
|
||||
item.Region = netflixRegion(env)
|
||||
default:
|
||||
item.Status = statusFromCode(first.StatusCode)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func netflixServed(code int) bool {
|
||||
return code == http.StatusOK || code == http.StatusMovedPermanently
|
||||
}
|
||||
|
||||
// netflixRegion reads the country out of the redirect Netflix would send a
|
||||
// visitor to, so the redirect must not be followed.
|
||||
func netflixRegion(env serviceEnv) string {
|
||||
result := env.send(probeRequest{
|
||||
method: http.MethodGet,
|
||||
url: "https://www.netflix.com/title/80018499",
|
||||
noRedirect: true,
|
||||
})
|
||||
if result == nil || result.Error != "" || result.header == nil {
|
||||
return ""
|
||||
}
|
||||
segments := strings.Split(result.header.Get("Location"), "/")
|
||||
if len(segments) < 4 {
|
||||
return ""
|
||||
}
|
||||
return normalizeRegion(strings.Split(segments[3], "-")[0])
|
||||
}
|
||||
|
||||
func netflixFromCdn(env serviceEnv) ServiceCheckItem {
|
||||
const url = "https://api.fast.com/netflix/speedtest/v2?https=true&token=YXNkZmFzZGxmbnNkYWZoYXNkZmhrYWxm&urlCount=5"
|
||||
result := env.get(url, serviceSmallMaxBody)
|
||||
item := itemFrom(result)
|
||||
if !answered(item, result) {
|
||||
return item
|
||||
}
|
||||
if result.StatusCode == http.StatusForbidden {
|
||||
item.Status = serviceBlocked
|
||||
return item
|
||||
}
|
||||
var payload struct {
|
||||
Targets []struct {
|
||||
Location struct {
|
||||
Country string `json:"country"`
|
||||
} `json:"location"`
|
||||
} `json:"targets"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(result.Body), &payload); err != nil || len(payload.Targets) == 0 {
|
||||
item.Status = serviceFailed
|
||||
return item
|
||||
}
|
||||
item.Region = normalizeRegion(payload.Targets[0].Location.Country)
|
||||
item.Status = serviceAvailable
|
||||
return item
|
||||
}
|
||||
|
||||
// checkDisneyPlus walks the browser hand-shake: register a device, exchange the
|
||||
// assertion for a refresh token, then ask the session which region it landed
|
||||
// in. Any step can answer on its own that the address is not welcome.
|
||||
func checkDisneyPlus(env serviceEnv) ServiceCheckItem {
|
||||
device := env.send(probeRequest{
|
||||
method: http.MethodPost,
|
||||
url: "https://disney.api.edge.bamgrid.com/devices",
|
||||
headers: map[string]string{
|
||||
"authorization": disneyAuthHeader,
|
||||
"content-type": "application/json; charset=UTF-8",
|
||||
},
|
||||
body: []byte(`{"deviceFamily":"browser","applicationRuntime":"chrome","deviceProfile":"windows","attributes":{}}`),
|
||||
maxBody: serviceSmallMaxBody,
|
||||
})
|
||||
item := itemFrom(device)
|
||||
if !answered(item, device) {
|
||||
return item
|
||||
}
|
||||
if device.StatusCode == http.StatusForbidden {
|
||||
item.Status = serviceBlocked
|
||||
return item
|
||||
}
|
||||
assertion := firstSubmatch(disneyAssertionPattern, device.Body)
|
||||
if assertion == "" {
|
||||
item.Status = serviceFailed
|
||||
return item
|
||||
}
|
||||
|
||||
form := strings.NewReplacer("{assertion}", url.QueryEscape(assertion)).Replace(
|
||||
"grant_type=urn:ietf:params:oauth:grant-type:token-exchange" +
|
||||
"&latitude=0&longitude=0&platform=browser" +
|
||||
"&subject_token={assertion}" +
|
||||
"&subject_token_type=urn:bamtech:params:oauth:token-type:device",
|
||||
)
|
||||
token := env.send(probeRequest{
|
||||
method: http.MethodPost,
|
||||
url: "https://disney.api.edge.bamgrid.com/token",
|
||||
headers: map[string]string{
|
||||
"authorization": disneyAuthHeader,
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: []byte(form),
|
||||
maxBody: serviceSmallMaxBody,
|
||||
})
|
||||
if tokenItem := itemFrom(token); !answered(tokenItem, token) {
|
||||
return tokenItem
|
||||
}
|
||||
if strings.Contains(token.Body, "forbidden-location") || strings.Contains(token.Body, "403 ERROR") {
|
||||
item.Status = serviceBlocked
|
||||
return item
|
||||
}
|
||||
refresh := firstSubmatch(disneyRefreshPattern, token.Body)
|
||||
if refresh == "" {
|
||||
item.Status = serviceFailed
|
||||
return item
|
||||
}
|
||||
|
||||
query := `{"query":"mutation refreshToken($input: RefreshTokenInput!) { refreshToken(refreshToken: $input) { activeSession { sessionId } } }","variables":{"input":{"refreshToken":"` +
|
||||
refresh + `"}}}`
|
||||
session := env.send(probeRequest{
|
||||
method: http.MethodPost,
|
||||
url: "https://disney.api.edge.bamgrid.com/graph/v1/device/graphql",
|
||||
headers: map[string]string{
|
||||
"authorization": disneyAuthHeader,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: []byte(query),
|
||||
maxBody: serviceSmallMaxBody,
|
||||
})
|
||||
sessionItem := itemFrom(session)
|
||||
if !answered(sessionItem, session) || session.Body == "" || session.StatusCode >= 400 {
|
||||
item.Status = serviceFailed
|
||||
return item
|
||||
}
|
||||
region := normalizeRegion(firstSubmatch(disneyCountryPattern, session.Body))
|
||||
if region == "" {
|
||||
item.Status = serviceUnavailable
|
||||
return item
|
||||
}
|
||||
item.Region = region
|
||||
switch firstSubmatch(disneySupportedPattern, session.Body) {
|
||||
case "true":
|
||||
item.Status = serviceAvailable
|
||||
case "false":
|
||||
item.Status = serviceComingSoon
|
||||
default:
|
||||
item.Status = serviceFailed
|
||||
}
|
||||
return item
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeRegion(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"US": "US",
|
||||
"jp": "JP",
|
||||
"CHN": "CN",
|
||||
"usa": "US",
|
||||
"HKG": "HK",
|
||||
"ZZZ": "",
|
||||
"C": "",
|
||||
"CNAAA": "",
|
||||
"": "",
|
||||
"1A": "",
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := normalizeRegion(input); got != want {
|
||||
t.Errorf("normalizeRegion(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRegionCoversTheBlockLists(t *testing.T) {
|
||||
for _, code := range geminiBlockedRegions {
|
||||
if normalizeRegion(code) == "" {
|
||||
t.Errorf("gemini block list entry %q does not fold to an alpha-2 code", code)
|
||||
}
|
||||
}
|
||||
for _, code := range claudeBlockedRegions {
|
||||
if normalizeRegion(code) != code {
|
||||
t.Errorf("claude block list entry %q is not already alpha-2", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The iOS endpoint turns API clients away even where ChatGPT works, so that
|
||||
// refusal is the success signal and anything else is not.
|
||||
func TestChatGptIosStatus(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Request is not allowed. Please try again later.": serviceAvailable,
|
||||
"You may be connected to a disallowed ISP": serviceDisallowedIsp,
|
||||
"Sorry, you have been blocked": serviceBlocked,
|
||||
"<html>something else entirely</html>": serviceFailed,
|
||||
"": serviceFailed,
|
||||
}
|
||||
for body, want := range cases {
|
||||
if got := chatGptIosStatus(&ProbeResult{Body: body}); got != want {
|
||||
t.Errorf("chatGptIosStatus(%q) = %q, want %q", body, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTikTokStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{http.StatusOK, `{"region":"JP"}`, serviceAvailable},
|
||||
{http.StatusForbidden, "", serviceUnsupportedRegion},
|
||||
{http.StatusUnavailableForLegalReasons, "", serviceUnsupportedRegion},
|
||||
{http.StatusOK, "Access Denied", serviceUnsupportedRegion},
|
||||
{http.StatusOK, "TikTok is not available in your region", serviceUnsupportedRegion},
|
||||
{http.StatusInternalServerError, "", serviceFailed},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
got := tikTokStatus(&ProbeResult{StatusCode: testCase.code, Body: testCase.body})
|
||||
if got != testCase.want {
|
||||
t.Errorf("tikTokStatus(%d, %q) = %q, want %q", testCase.code, testCase.body, got, testCase.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTikTokRegionReadsTheAppContext(t *testing.T) {
|
||||
const cluster = `{"idc":"no1a","vdc":"no1a","region":"EU-TTP2","vregion":"EU-TTP2"}`
|
||||
page := cluster + `{"__DEFAULT_SCOPE__":{"webapp.app-context":{"language":"en","region":"GB","appId":1233}}}`
|
||||
if got := tikTokRegion(page); got != "GB" {
|
||||
t.Errorf("region = %q, want the visitor's country rather than the serving cluster", got)
|
||||
}
|
||||
if got := tikTokRegion(cluster); got != "" {
|
||||
t.Errorf("region = %q, want empty without the app context", got)
|
||||
}
|
||||
if got := tikTokRegion(`{"webapp.app-context":{"region":"zh-Hant-TW"}}`); got != "" {
|
||||
t.Errorf("region = %q, want a locale left unread rather than its language", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpotifyRegionPrefersTheRedirectedPath(t *testing.T) {
|
||||
redirected := &ProbeResult{Url: "https://www.spotify.com/de-de/", Body: `{"countryCode":"US"}`}
|
||||
if got := spotifyRegion(redirected); got != "de" {
|
||||
t.Errorf("region = %q, want the path's country", got)
|
||||
}
|
||||
unredirected := &ProbeResult{
|
||||
Url: "https://www.spotify.com/api/content/v1/country-selector",
|
||||
Body: `{"countryCode":"SE"}`,
|
||||
}
|
||||
if got := spotifyRegion(unredirected); got != "SE" {
|
||||
t.Errorf("region = %q, want the payload's country", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYouTubeRegionPatterns(t *testing.T) {
|
||||
bodies := map[string]string{
|
||||
`<span id="country-code"> GB </span>`: "GB",
|
||||
`{"GL":"JP","other":1}`: "JP",
|
||||
`{"countryCode":"KR"}`: "KR",
|
||||
`{"country_code":"BR"}`: "BR",
|
||||
}
|
||||
for body, want := range bodies {
|
||||
var got string
|
||||
for _, pattern := range youTubeRegionPatterns {
|
||||
if code := firstSubmatch(pattern, body); code != "" {
|
||||
got = normalizeRegion(code)
|
||||
break
|
||||
}
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("region from %q = %q, want %q", body, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisneyAndPrimePatterns(t *testing.T) {
|
||||
if got := firstSubmatch(disneyAssertionPattern, `{"assertion":"abc.def"}`); got != "abc.def" {
|
||||
t.Errorf("assertion = %q", got)
|
||||
}
|
||||
if got := firstSubmatch(disneyRefreshPattern, `{"refresh_token":"tok123"}`); got != "tok123" {
|
||||
t.Errorf("refresh token = %q", got)
|
||||
}
|
||||
if got := firstSubmatch(disneySupportedPattern, `{"inSupportedLocation":false}`); got != "false" {
|
||||
t.Errorf("supported = %q", got)
|
||||
}
|
||||
if got := firstSubmatch(primeRegionPattern, `{"currentTerritory":"NL"}`); got != "NL" {
|
||||
t.Errorf("territory = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func Start(fd int, stack string, address, dns string) *sing_tun.Listener {
|
||||
var prefix6 []netip.Prefix
|
||||
tunStack, ok := constant.StackTypeMapping[strings.ToLower(stack)]
|
||||
if !ok {
|
||||
tunStack = constant.TunSystem
|
||||
tunStack = constant.TunMips
|
||||
}
|
||||
for _, a := range strings.Split(address, ",") {
|
||||
a = strings.TrimSpace(a)
|
||||
|
||||
@@ -6,12 +6,15 @@ 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:material_ui/material_ui.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -56,6 +59,17 @@ 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;
|
||||
@@ -85,7 +99,9 @@ class ApplicationState extends ConsumerState<Application> {
|
||||
}
|
||||
_autoUpdateProfilesTask();
|
||||
_initLink();
|
||||
unawaited(app?.initShortcuts());
|
||||
if (!safeModeBuild) {
|
||||
unawaited(app?.initShortcuts());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -136,7 +152,7 @@ class ApplicationState extends ConsumerState<Application> {
|
||||
unawaited(ref.read(systemActionProvider.notifier).updateLocalIp());
|
||||
final hasVpn = results.contains(ConnectivityResult.vpn);
|
||||
if (_preHasVpn == hasVpn) {
|
||||
ref.read(checkIpNumProvider.notifier).add();
|
||||
ref.read(routeTrackerProvider.notifier).bumpHostEpoch();
|
||||
}
|
||||
_preHasVpn = hasVpn;
|
||||
}
|
||||
@@ -158,18 +174,10 @@ class ApplicationState extends ConsumerState<Application> {
|
||||
...GlobalMaterialLocalizations.delegates,
|
||||
],
|
||||
builder: (context, child) {
|
||||
// The bridge's legacy Theme swaps in its own default IconTheme color,
|
||||
// which material_ui IconButton.filled reads as custom and loses onPrimary.
|
||||
// ignore: deprecated_member_use
|
||||
return MaterialUiCompatibilityBridge(
|
||||
child: IconTheme(
|
||||
data: Theme.of(context).iconTheme,
|
||||
child: buildManagerStack(
|
||||
isDesktop: system.isDesktop,
|
||||
onConnectivityChanged: _handleConnectivityChanged,
|
||||
child: child!,
|
||||
),
|
||||
),
|
||||
return buildManagerStack(
|
||||
isDesktop: system.isDesktop,
|
||||
onConnectivityChanged: _handleConnectivityChanged,
|
||||
child: RemoteFocusAdapter(enabled: system.isTV, child: child!),
|
||||
);
|
||||
},
|
||||
scrollBehavior: const BaseScrollBehavior(),
|
||||
@@ -180,16 +188,16 @@ class ApplicationState extends ConsumerState<Application> {
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
pageTransitionsTheme: _pageTransitionsTheme,
|
||||
actionIconTheme: _actionIconTheme,
|
||||
colorScheme: _getAppColorScheme(brightness: Brightness.light),
|
||||
).withAppShapes,
|
||||
darkTheme: ThemeData(
|
||||
useMaterial3: true,
|
||||
pageTransitionsTheme: _pageTransitionsTheme,
|
||||
colorScheme: _getAppColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
).toPureBlack(themeProps.pureBlack),
|
||||
actionIconTheme: _actionIconTheme,
|
||||
colorScheme: _getAppColorScheme(brightness: Brightness.dark),
|
||||
).withAppShapes,
|
||||
home: child!,
|
||||
home: KeyboardInsetHold(child: child!),
|
||||
);
|
||||
},
|
||||
child: const HomePage(),
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/views/disclaimer.dart';
|
||||
import 'package:fl_clash/views/navigation.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -241,7 +242,7 @@ class Bootstrap {
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
final isDisclaimerAccepted = await dialogs.showDisclaimer();
|
||||
final isDisclaimerAccepted = await requestDisclaimerConsent();
|
||||
if (!isDisclaimerAccepted) {
|
||||
await _container.read(systemActionProvider.notifier).handleExit();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:dio/dio.dart';
|
||||
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';
|
||||
@@ -7,22 +7,6 @@ import 'dart:ui';
|
||||
|
||||
final currentAppLocalizations = AppLocalizations.current;
|
||||
|
||||
String? networkErrorMessage(Object error, AppLocalizations appLocalizations) {
|
||||
if (error case CoreMethodException(:final code)) {
|
||||
return switch (code) {
|
||||
'request_bad_response' => appLocalizations.networkException,
|
||||
'request_error' => appLocalizations.unknownNetworkError,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
if (error is DioException) {
|
||||
return error.type == DioExceptionType.badResponse
|
||||
? appLocalizations.networkException
|
||||
: appLocalizations.unknownNetworkError;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? coreLaunchBlockedMessage(
|
||||
Object error,
|
||||
AppLocalizations appLocalizations,
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import 'dart:ui' show Brightness, Color;
|
||||
|
||||
import 'package:fl_clash/common/provider_reader.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
|
||||
abstract interface class WindowPort {
|
||||
Future<WindowProps?> captureNormalGeometry(WindowProps current);
|
||||
|
||||
/// Returns whether a backdrop is now drawn behind the content.
|
||||
Future<bool> setBlur({
|
||||
required bool enabled,
|
||||
required Brightness brightness,
|
||||
required Color tint,
|
||||
});
|
||||
|
||||
Future<void> show();
|
||||
|
||||
Future<void> hide();
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive_io.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_flutter/drift_flutter.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/common/migration.dart';
|
||||
import 'package:fl_clash/database/database.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
enum BackupFailure { invalid, newerVersion }
|
||||
|
||||
final class BackupException implements Exception {
|
||||
final BackupFailure failure;
|
||||
|
||||
const BackupException(this.failure);
|
||||
|
||||
@override
|
||||
String toString() => 'BackupException(${failure.name})';
|
||||
}
|
||||
|
||||
/// Archive entries mirror the data directory, so an entry name is also the
|
||||
/// file's path relative to the home directory.
|
||||
abstract final class BackupEntries {
|
||||
static String profile(Object id) => '$profilesDirectoryName/$id.yaml';
|
||||
|
||||
static String script(Object id) => 'scripts/$id.js';
|
||||
|
||||
static String provider(ClashProvider provider) =>
|
||||
'$providersDirectoryName/${providerCacheDirectoryName(provider.kind)}/'
|
||||
'${provider.fileName}';
|
||||
|
||||
/// A remote provider's cache belongs to the Core and downloads again.
|
||||
static List<String> of({
|
||||
required Iterable<int> profileIds,
|
||||
required Iterable<int> scriptIds,
|
||||
required Iterable<ClashProvider> providers,
|
||||
}) {
|
||||
return [
|
||||
...profileIds.map(profile),
|
||||
...scriptIds.map(script),
|
||||
...providers.where((item) => !item.isRemote).map(provider),
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> ofData(MigrationData data) {
|
||||
return of(
|
||||
profileIds: data.profiles.map((item) => item.id),
|
||||
scriptIds: data.scripts.map((item) => item.id),
|
||||
providers: data.clashProviders,
|
||||
);
|
||||
}
|
||||
|
||||
static String resolve(String root, String entry) {
|
||||
return joinAll([root, ...posix.split(entry)]);
|
||||
}
|
||||
}
|
||||
|
||||
typedef BackupArchiveRequest = ({
|
||||
String archivePath,
|
||||
Map<String, Object?> configMap,
|
||||
String databaseSnapshotPath,
|
||||
String homeDirPath,
|
||||
List<String> entries,
|
||||
});
|
||||
|
||||
typedef RestoreArchiveRequest = ({String archivePath, String stagingDirPath});
|
||||
|
||||
Future<void> backupTask(BackupArchiveRequest request) {
|
||||
return compute(_backupTask, request);
|
||||
}
|
||||
|
||||
Future<void> _backupTask(BackupArchiveRequest request) {
|
||||
return writeBackupArchive(
|
||||
archivePath: request.archivePath,
|
||||
configMap: request.configMap,
|
||||
databaseSnapshotPath: request.databaseSnapshotPath,
|
||||
homeDirPath: request.homeDirPath,
|
||||
entries: request.entries,
|
||||
);
|
||||
}
|
||||
|
||||
Future<MigrationData> restoreTask(RestoreArchiveRequest request) {
|
||||
return compute(_restoreTask, request);
|
||||
}
|
||||
|
||||
Future<MigrationData> _restoreTask(RestoreArchiveRequest request) {
|
||||
return readBackupArchive(
|
||||
archivePath: request.archivePath,
|
||||
stagingDirPath: request.stagingDirPath,
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> writeBackupArchive({
|
||||
required String archivePath,
|
||||
required Map<String, Object?> configMap,
|
||||
required String databaseSnapshotPath,
|
||||
required String homeDirPath,
|
||||
required Iterable<String> entries,
|
||||
}) async {
|
||||
final encoder = ZipFileEncoder()..create(archivePath);
|
||||
try {
|
||||
encoder.addArchiveFile(
|
||||
ArchiveFile.string(configJsonName, json.encode(configMap)),
|
||||
);
|
||||
await encoder.addFile(File(databaseSnapshotPath), backupDatabaseName);
|
||||
for (final entry in entries) {
|
||||
final file = File(BackupEntries.resolve(homeDirPath, entry));
|
||||
if (await file.exists()) {
|
||||
await encoder.addFile(file, entry);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await encoder.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes nothing outside [stagingDirPath]; the files the result names are left
|
||||
/// there at their [BackupEntries] paths.
|
||||
@visibleForTesting
|
||||
Future<MigrationData> readBackupArchive({
|
||||
required String archivePath,
|
||||
required String stagingDirPath,
|
||||
}) async {
|
||||
await _extract(archivePath, stagingDirPath);
|
||||
final configFile = File(join(stagingDirPath, configJsonName));
|
||||
if (!await configFile.exists()) {
|
||||
throw const BackupException(BackupFailure.invalid);
|
||||
}
|
||||
final configMap = _decodeConfig(await configFile.readAsString());
|
||||
final version = configMap['version'] ?? 0;
|
||||
if (version is! int) {
|
||||
throw const BackupException(BackupFailure.invalid);
|
||||
}
|
||||
if (version > Migration.currentVersion) {
|
||||
throw const BackupException(BackupFailure.newerVersion);
|
||||
}
|
||||
if (version == 0) {
|
||||
return migrateLegacyConfig(
|
||||
configMap: configMap,
|
||||
sourcePath: stagingDirPath,
|
||||
targetPath: stagingDirPath,
|
||||
);
|
||||
}
|
||||
final databaseFile = File(join(stagingDirPath, backupDatabaseName));
|
||||
if (!await databaseFile.exists()) {
|
||||
return MigrationData(configMap: configMap);
|
||||
}
|
||||
final schemaVersion = await _readSchemaVersion(databaseFile);
|
||||
final database = Database(
|
||||
driftDatabase(
|
||||
name: basenameWithoutExtension(backupDatabaseName),
|
||||
// Left unset, drift asks path_provider for a temp directory, and this
|
||||
// isolate has no platform channel to answer it.
|
||||
native: DriftNativeOptions(
|
||||
databaseDirectory: () async => Directory(stagingDirPath),
|
||||
tempDirectoryPath: () async => stagingDirPath,
|
||||
),
|
||||
),
|
||||
);
|
||||
try {
|
||||
if (schemaVersion > database.schemaVersion) {
|
||||
throw const BackupException(BackupFailure.newerVersion);
|
||||
}
|
||||
final results = await Future.wait([
|
||||
database.profilesDao.query().get(),
|
||||
database.scriptsDao.query().get(),
|
||||
database.rules.all().map((item) => item.toRule()).get(),
|
||||
(database.select(database.profileRuleLinks)..orderBy([
|
||||
(t) => OrderingTerm(expression: t.order, nulls: NullsOrder.last),
|
||||
]))
|
||||
.map((item) => item.toLink())
|
||||
.get(),
|
||||
(database.select(database.proxyGroups)..orderBy([
|
||||
(t) => OrderingTerm(expression: t.order, nulls: NullsOrder.last),
|
||||
]))
|
||||
.map((item) => item.toProxyGroup())
|
||||
.get(),
|
||||
database.clashProvidersDao.queryAll().get(),
|
||||
(database.select(database.customProxies)..orderBy([
|
||||
(t) => OrderingTerm(expression: t.order, nulls: NullsOrder.last),
|
||||
]))
|
||||
.map((item) => item.toCustomProxy())
|
||||
.get(),
|
||||
]);
|
||||
return MigrationData(
|
||||
configMap: configMap,
|
||||
profiles: results[0].cast<Profile>(),
|
||||
scripts: results[1].cast<Script>(),
|
||||
rules: results[2].cast<Rule>(),
|
||||
links: results[3].cast<ProfileRuleLink>(),
|
||||
proxyGroups: results[4].cast<ProxyGroup>(),
|
||||
clashProviders: results[5].cast<ClashProvider>(),
|
||||
customProxies: results[6].cast<CustomProxy>(),
|
||||
);
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _extract(String archivePath, String stagingDirPath) async {
|
||||
await Directory(stagingDirPath).create(recursive: true);
|
||||
final input = InputFileStream(archivePath);
|
||||
try {
|
||||
final Archive archive;
|
||||
try {
|
||||
archive = ZipDecoder().decodeStream(input);
|
||||
} on Exception {
|
||||
throw const BackupException(BackupFailure.invalid);
|
||||
}
|
||||
for (final file in archive.files) {
|
||||
if (!file.isFile) {
|
||||
continue;
|
||||
}
|
||||
final outPath = _stagedEntryPath(stagingDirPath, file.name);
|
||||
if (outPath == null) {
|
||||
continue;
|
||||
}
|
||||
await Directory(dirname(outPath)).create(recursive: true);
|
||||
final output = OutputFileStream(outPath);
|
||||
try {
|
||||
file.writeContent(output);
|
||||
} finally {
|
||||
await output.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await input.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// `posix.normalize` collapses `a/../b`, but a name that starts with `../`
|
||||
/// normalizes to itself and an absolute one stays absolute, so either would
|
||||
/// write wherever the archive asks. A backup file is untrusted input.
|
||||
String? _stagedEntryPath(String stagingDirPath, String name) {
|
||||
final normalized = posix.normalize(name.replaceAll('\\', '/'));
|
||||
if (normalized.isEmpty ||
|
||||
posix.isAbsolute(normalized) ||
|
||||
normalized == '..' ||
|
||||
normalized.startsWith('../')) {
|
||||
return null;
|
||||
}
|
||||
final outPath = normalize(BackupEntries.resolve(stagingDirPath, normalized));
|
||||
if (!isWithin(stagingDirPath, outPath)) {
|
||||
return null;
|
||||
}
|
||||
return outPath;
|
||||
}
|
||||
|
||||
Map<String, Object?> _decodeConfig(String source) {
|
||||
final Object? decoded;
|
||||
try {
|
||||
decoded = json.decode(source);
|
||||
} on FormatException {
|
||||
throw const BackupException(BackupFailure.invalid);
|
||||
}
|
||||
if (decoded is! Map<String, Object?>) {
|
||||
throw const BackupException(BackupFailure.invalid);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/// Opening the database through drift would already run its migrations, so the
|
||||
/// version is read from the file header: SQLite keeps `user_version` as a
|
||||
/// big-endian integer at byte 60.
|
||||
Future<int> _readSchemaVersion(File databaseFile) async {
|
||||
final handle = await databaseFile.open();
|
||||
try {
|
||||
final header = await handle.read(64);
|
||||
if (header.length < 64) {
|
||||
throw const BackupException(BackupFailure.invalid);
|
||||
}
|
||||
return ByteData.sublistView(header).getInt32(60);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
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 {
|
||||
@@ -84,15 +86,6 @@ extension ColorExtension on Color {
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
Color darken([final int amount = 10]) {
|
||||
if (amount <= 0) return this;
|
||||
if (amount > 100) return Colors.black;
|
||||
final HSLColor hsl = HSLColor.fromColor(this);
|
||||
return hsl
|
||||
.withLightness(min(1, max(0, hsl.lightness - amount / 100)))
|
||||
.toColor();
|
||||
}
|
||||
|
||||
Color blendDarken(BuildContext context, {double factor = 0.1}) {
|
||||
final brightness = Theme.of(context).brightness;
|
||||
return Color.lerp(
|
||||
@@ -104,17 +97,40 @@ extension ColorExtension on Color {
|
||||
}
|
||||
|
||||
extension ColorSchemeExtension on ColorScheme {
|
||||
ColorScheme toPureBlack(bool isPrueBlack) => isPrueBlack
|
||||
? copyWith(
|
||||
surface: Colors.black,
|
||||
surfaceContainer: surfaceContainer.darken(5),
|
||||
)
|
||||
: this;
|
||||
}
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
Color? getDelayColor(int? delay) {
|
||||
if (delay == null) return null;
|
||||
if (delay < 0) return Colors.red;
|
||||
if (delay < 600) return Colors.green;
|
||||
return const Color(0xFFC57F0A);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export 'app_localizations.dart';
|
||||
export 'app_ports.dart';
|
||||
export 'backup.dart';
|
||||
export 'changelog.dart';
|
||||
export 'color.dart';
|
||||
export 'compute.dart';
|
||||
@@ -9,13 +10,14 @@ export 'converter.dart';
|
||||
export 'datetime.dart';
|
||||
export 'dialog.dart';
|
||||
export 'exception.dart';
|
||||
export 'feature.dart';
|
||||
export 'file.dart';
|
||||
export 'fixed.dart';
|
||||
export 'function.dart';
|
||||
export 'future.dart';
|
||||
export 'http.dart';
|
||||
export 'icons.dart';
|
||||
export 'indexing.dart';
|
||||
export 'input_entries.dart';
|
||||
export 'input_limits.dart';
|
||||
export 'iterable.dart';
|
||||
export 'javascript.dart';
|
||||
@@ -23,11 +25,14 @@ export 'keyboard.dart';
|
||||
export 'l10n_labels.dart';
|
||||
export 'layout.dart';
|
||||
export 'link.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';
|
||||
export 'package.dart';
|
||||
export 'path.dart';
|
||||
@@ -37,9 +42,9 @@ export 'print.dart';
|
||||
export 'protocol.dart';
|
||||
export 'provider_reader.dart';
|
||||
export 'proxy.dart';
|
||||
export 'render.dart';
|
||||
export 'request.dart';
|
||||
export 'scroll.dart';
|
||||
export 'search.dart';
|
||||
export 'shape.dart';
|
||||
export 'snowflake.dart';
|
||||
export 'string.dart';
|
||||
|
||||