refactor(window,android): move the window header into the fork and tighten the build
Crash recovery decides from a boot sentinel rather than Crashlytics, so it no longer depends on a reporting SDK having initialized. The window header is built into the window manager fork instead of being reassembled by the app, and the toolchain tightens dependency pins, plugin analysis, CI, and the host-agnostic tests. Changelog: Recovery after a crash no longer waits on the crash reporter to start Changelog-Type: fix
This commit is contained in:
@@ -530,7 +530,6 @@ Architecture detection is automatic. The `--description` flag passed to `flutter
|
||||
- `rust_api`: runtime Flutter Rust Bridge FFI plugin built through Cargokit.
|
||||
- `tray`: system tray for Linux, macOS and Windows. Written for FlClash; replaced the `tray_manager` fork.
|
||||
- `wifi_ssid`: Wi-Fi SSID detection.
|
||||
- `window_ext`: window extensions.
|
||||
- `flutter_distributor`: app packaging/distribution.
|
||||
|
||||
## Rust Helper Service
|
||||
|
||||
+19
-8
@@ -12,7 +12,8 @@ FlClash is a multi-platform proxy client based on ClashMeta (mihomo), built with
|
||||
|
||||
## Forked Dependencies
|
||||
|
||||
Three `pubspec.yaml` dependencies are pinned to a fork by commit SHA. All three
|
||||
Three `pubspec.yaml` dependencies are pinned to a fork — `window_manager` by tag,
|
||||
the other two by commit SHA. All three
|
||||
forks live under `chen08209`, the same account that owns this repository, so they
|
||||
are maintained in-house rather than tracked from a third party: advancing a pin
|
||||
is a local decision, and there is no external maintainer to wait on for the patch
|
||||
@@ -29,14 +30,24 @@ diff -ru ~/.pub-cache/hosted/pub.dev/<name>-<version> ~/.pub-cache/git/<name>-<s
|
||||
```
|
||||
|
||||
`window_manager` — `chen08209/window_manager`, path `packages/window_manager`,
|
||||
version 0.5.1.
|
||||
version 0.5.1, pinned to the tag `v0.5.1-flclash.1` because the fork carries
|
||||
commits of its own rather than a single patch on top of a release.
|
||||
|
||||
- Changes `windows/window_manager_plugin.cpp` only. With `titleBarStyle: hidden`
|
||||
a maximized window uses the monitor work area (`GetMonitorInfo().rcWork`)
|
||||
instead of upstream's `adjustNCCALCSIZE` border fudge, so the maximized window
|
||||
no longer covers the taskbar.
|
||||
- Drop the fork once upstream constrains a hidden-title-bar maximized window to
|
||||
the work area on Windows. Dart API is untouched, so nothing in `lib/` changes.
|
||||
- `windows/window_manager_plugin.cpp`: with `titleBarStyle: hidden` a maximized
|
||||
window uses the monitor work area (`GetMonitorInfo().rcWork`) instead of
|
||||
upstream's `adjustNCCALCSIZE` border fudge, so it no longer covers the taskbar.
|
||||
- `linux/window_manager_plugin.cc`: GTK drops the placement of an unmapped
|
||||
window, so `hide` saves the geometry and the `map-event` handler applies it
|
||||
again. Upstream only moves the window while it is hidden, which a window
|
||||
manager is free to ignore — the window then reappears wherever it decides to
|
||||
place it, which on this repository's Linux runner is every appearance after the
|
||||
first, because `my_application.cc` never shows the toplevel itself.
|
||||
- Adds `setWindowCornerPreference` (Windows) and `handleShouldTerminate` /
|
||||
`onWindowShouldTerminate` (macOS). These lived in a local `window_ext` plugin
|
||||
until they moved here; `lib/manager/window_manager.dart` and
|
||||
`macos/Runner/AppDelegate.swift` are the callers.
|
||||
- Drop the fork once upstream carries all three. The added APIs have call sites,
|
||||
so this is not a pin change alone.
|
||||
|
||||
`launch_at_startup` — `chen08209/launch_at_startup`, version 0.5.1.
|
||||
|
||||
|
||||
@@ -184,6 +184,24 @@ the invariant hard to break beats prose that asks the next reader not to break i
|
||||
|
||||
## Lifecycle Rules
|
||||
|
||||
- Crash recovery is owned by `BootGuard` (`lib/common/boot_guard.dart`), and the signal it acts on is the persisted
|
||||
`BootRecord`, never a crash reporter. The record is stamped `starting` before `startCore` and `running` once
|
||||
`_initApp` finishes, so only a launch that died before reaching `running` is a failed launch — a crash after hours of
|
||||
runtime, or a process the system reclaimed in the background, is not one. `ApplicationExitInfo` can only veto a
|
||||
failure (user stop, low memory, signal, package change), never create one, and its timestamp is consumed through
|
||||
`handledExitAt` so the same exit cannot be counted twice.
|
||||
- `BootGuard` is an Android-only mechanism and gates itself: on every other platform `evaluate`, `markRunning` and
|
||||
`markClosed` return without touching preferences. The desktop has neither of the two attribution sources — no
|
||||
`ApplicationExitInfo` equivalent and no Crashlytics toggle — so a bare sentinel there would read a window closed on
|
||||
the disclaimer dialog, or a session ended by shutdown, as a failed launch. Keep the platform check inside the guard;
|
||||
callers in `bootstrap.dart` and `SystemAction` stay unconditional.
|
||||
- `FirebaseCrashlytics.didCrashOnPreviousExecution()` corroborates a failure and must not trigger one. Its marker file is
|
||||
cleared by a background initialization that `dataCollectionArbiter` blocks while collection is disabled, which is the
|
||||
default here, so one real crash makes it return true on every later launch. That latch is what made the app clear the
|
||||
selected profile on every cold start; it is also why the probe is only read when `crashlytics` is enabled, keeping
|
||||
Firebase uninitialized until the user consents.
|
||||
- Recovery is graded: the first failed launch only skips `initStatus`, and `currentProfileId` is cleared only from
|
||||
`crashRecoveryClearThreshold` consecutive failures on. Do not let a single interrupted launch write to the config.
|
||||
- Desktop process ownership belongs to `DesktopCoreLifecycle`; do not start/kill `FlClashCore` from providers, widgets,
|
||||
managers, or ad hoc exit callbacks. Acquire and release it through a `CoreProcessLease`.
|
||||
- `CoreController.close()` and platform `close()` implementations are terminal and idempotent. Application shutdown must
|
||||
@@ -269,6 +287,27 @@ does open a file-backed database owns closing it.
|
||||
(`ubuntu-latest`) and a macOS working copy measure different coverage for the same test. Assert host-agnostic behavior,
|
||||
and leave headroom under a group floor that covers such a branch.
|
||||
|
||||
A platform decision that drives layout takes `isDesktop`/`isMacOS` as parameters and reads `system` only at the call site,
|
||||
so every platform's outcome is reachable from one host. `getWindowHeaderHeight` and `showsWindowHeader` in
|
||||
`lib/common/layout.dart` own the window header rule for both `WindowHeaderContainer` and `overlayTopOffset` — they must
|
||||
agree, or the content is offset by a header that is not there. `WindowHeaderBar` takes its height and slots as arguments,
|
||||
which is what lets `test/manager/window_header_test.dart` measure the Windows caption bar on a macOS host.
|
||||
|
||||
A `Stack` under loose constraints sizes to its non-positioned children, and falls back to `constraints.biggest` only when
|
||||
it has none — so a bar that fills the window must keep every slot positioned. One non-positioned child is enough to
|
||||
collapse `WindowHeaderBar` to that child's width: the macOS title did exactly that, leaving the app name over the traffic
|
||||
lights and the raw window painted black beside it, while Windows, whose slots are all positioned, stayed correct.
|
||||
`WindowHeaderLayout` positions the header across the top for the same reason, and the macOS group asserts the bar's
|
||||
width, not just its height — a height-only assertion cannot see this.
|
||||
|
||||
The same seam carries the two other places a platform decision changed what was built: `AppTray` holds `isMacOS`/
|
||||
`isWindows` as state — `AppTray()` fills them from `system`, `AppTray.forPlatform` is the test seam — and `OnDemandView`
|
||||
takes nullable `isAndroid`/`isMacOS` that fall back to the host. Every test names the platform it means, because
|
||||
`debugDefaultTargetPlatformOverride` does not move `system` and a suite that leaves it to the host asserts the macOS
|
||||
shape on a developer machine and the Linux one on CI. `WindowHeaderContainer` builds the caption buttons on every
|
||||
non-macOS host, so a test mounting it needs `TestApp` for `AppLocalizations` and a `window_manager` channel mock that
|
||||
answers `isMaximized`/`isAlwaysOnTop` with a bool.
|
||||
|
||||
Auto-dispose providers need a container-level hold before a test reads them back. `proxyGroupProvider`, `ruleProvider`,
|
||||
`itemsProvider` and friends mix in `AutoDisposeNotifierMixin`, so a `container.read` that no widget is currently watching
|
||||
rebuilds the provider from its override and silently discards whatever the code under test wrote. Add
|
||||
@@ -291,6 +330,14 @@ if that wrapper disappears). Reuse an existing string before adding one; a label
|
||||
inside the `ValueListenableBuilder`, not outside it, or the tooltip cannot follow the icon. A row of window buttons hidden
|
||||
behind `system.isMacOS` is unreachable from a macOS test host, so extract it — `WindowHeaderActions` is the pattern.
|
||||
|
||||
A tooltip needs an `Overlay` ancestor at build time, not at hover time: `RawTooltip` builds an `OverlayPortal`, so a
|
||||
button whose `tooltip` has nowhere to go throws "No Overlay widget found" and takes its whole subtree down with it.
|
||||
Everything `buildManagerStack` wraps around `MaterialApp.builder`'s child sits *above* the app Navigator and therefore
|
||||
above the only Overlay in the tree. A manager that renders a tooltip — `WindowHeaderLayout` and its caption buttons are
|
||||
the case that broke Windows while macOS, which renders a bare title there, stayed clean — hosts its own with
|
||||
`Overlay.wrap`, spanning the window so the tooltip is not clipped to the widget that owns it. A test only reproduces this
|
||||
by mirroring that topology: build the widget from `MaterialApp.builder`, never from `home:`.
|
||||
|
||||
A public top-level declaration that nothing outside its own file references is dead, and no lint catches it:
|
||||
`unused_element` covers only private ones, and a barrel `export` keeps a dead file compiling and off every
|
||||
"unused import" report. `test/lint/dead_file_test.dart` scans `lib/` for files whose declared names — types plus the
|
||||
|
||||
@@ -138,6 +138,10 @@ jobs:
|
||||
echo "flutter.sdk=$FLUTTER_ROOT"
|
||||
echo "sdk.dir=$ANDROID_HOME"
|
||||
} > local.properties
|
||||
wrapper="$FLUTTER_ROOT/bin/cache/artifacts/gradle_wrapper"
|
||||
test -d "$wrapper" || flutter precache
|
||||
cp -R "$wrapper/." .
|
||||
chmod +x gradlew
|
||||
# Modules are listed explicitly: the aggregate testDebugUnitTest task would
|
||||
# also run third-party Flutter plugin suites. `:core` is omitted because its
|
||||
# only class loads the native library through System.loadLibrary.
|
||||
|
||||
@@ -156,7 +156,15 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
}
|
||||
|
||||
"didCrashOnPreviousExecution" -> {
|
||||
result.success(GlobalState.didCrashOnPreviousExecution())
|
||||
scope.launch(Dispatchers.IO) {
|
||||
result.success(GlobalState.didCrashOnPreviousExecution())
|
||||
}
|
||||
}
|
||||
|
||||
"getLastExitInfo" -> {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
result.success(GlobalState.lastExitInfo())
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.google.firebase.FirebaseApp
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
@@ -11,6 +13,7 @@ import kotlinx.coroutines.SupervisorJob
|
||||
object GlobalState : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||
const val NOTIFICATION_CHANNEL = "FlClash"
|
||||
const val NOTIFICATION_ID = 1
|
||||
private const val EXIT_INFO_LIMIT = 8
|
||||
|
||||
val packageName: String
|
||||
get() = application.packageName
|
||||
@@ -44,4 +47,17 @@ object GlobalState : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatch
|
||||
FirebaseApp.initializeApp(application)
|
||||
return FirebaseCrashlytics.getInstance().didCrashOnPreviousExecution()
|
||||
}
|
||||
|
||||
fun lastExitInfo(): Map<String, Any?>? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
|
||||
val manager = application.getSystemService(ActivityManager::class.java) ?: return null
|
||||
val info = runCatching {
|
||||
manager.getHistoricalProcessExitReasons(application.packageName, 0, EXIT_INFO_LIMIT)
|
||||
}.getOrNull()?.firstOrNull { it.processName == application.packageName } ?: return null
|
||||
return mapOf(
|
||||
"reason" to info.reason,
|
||||
"timestamp" to info.timestamp,
|
||||
"description" to info.description,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -325,7 +325,9 @@
|
||||
"messageTestTip": "This is a message.",
|
||||
"crashTest": "Crash test",
|
||||
"crashDetected": "Crash detected",
|
||||
"crashDetectedTip": "The app crashed during the previous run. To prevent repeated crashes, the current profile has been cleared and automatic configuration setup was skipped.",
|
||||
"crashDetectedTip": "The app failed to finish launching twice in a row. To break the loop, the profile {name} has been deselected and automatic setup was skipped. You can select it again at any time.",
|
||||
"launchInterrupted": "Launch did not finish",
|
||||
"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",
|
||||
"internet": "Internet",
|
||||
|
||||
+3
-1
@@ -325,7 +325,9 @@
|
||||
"messageTestTip": "これはメッセージです。",
|
||||
"crashTest": "クラッシュテスト",
|
||||
"crashDetected": "クラッシュを検出しました",
|
||||
"crashDetectedTip": "前回の実行中にアプリがクラッシュしました。クラッシュの繰り返しを防ぐため、現在のプロファイルを解除し、設定の自動セットアップをスキップしました。",
|
||||
"crashDetectedTip": "アプリの起動が 2 回連続で完了しませんでした。繰り返しを断ち切るため、プロファイル {name} の選択を解除し、設定の自動セットアップをスキップしました。いつでも選択し直せます。",
|
||||
"launchInterrupted": "起動が完了しませんでした",
|
||||
"launchInterruptedTip": "前回、アプリは起動中に予期せず終了しました。今回は設定の自動セットアップをスキップしました。手動で起動して再試行できます。",
|
||||
"clearData": "データを消去",
|
||||
"textScale": "テキストスケーリング",
|
||||
"internet": "インターネット",
|
||||
|
||||
+3
-1
@@ -325,7 +325,9 @@
|
||||
"messageTestTip": "Это сообщение.",
|
||||
"crashTest": "Тест на сбои",
|
||||
"crashDetected": "Обнаружен сбой",
|
||||
"crashDetectedTip": "Во время предыдущего запуска произошёл сбой приложения. Чтобы предотвратить повторный сбой, текущий профиль был сброшен, а автоматическая настройка конфигурации пропущена.",
|
||||
"crashDetectedTip": "Приложение два раза подряд не смогло завершить запуск. Чтобы разорвать цикл, профиль {name} снят с выбора, а автоматическая настройка пропущена. Вы можете выбрать его снова в любой момент.",
|
||||
"launchInterrupted": "Запуск не завершён",
|
||||
"launchInterruptedTip": "В прошлый раз приложение неожиданно завершилось во время запуска. Автоматическая настройка для этого запуска пропущена; вы можете запустить её вручную.",
|
||||
"clearData": "Очистить данные",
|
||||
"textScale": "Масштабирование текста",
|
||||
"internet": "Интернет",
|
||||
|
||||
+3
-1
@@ -325,7 +325,9 @@
|
||||
"messageTestTip": "这是一条消息。",
|
||||
"crashTest": "崩溃测试",
|
||||
"crashDetected": "检测到崩溃",
|
||||
"crashDetectedTip": "检测到应用上次运行发生崩溃。为避免重复崩溃,已清除当前配置选择,并跳过本次自动配置。",
|
||||
"crashDetectedTip": "应用连续两次未能完成启动。为打断崩溃循环,已取消选中配置 {name},并跳过本次自动配置,你可以随时重新选中它。",
|
||||
"launchInterrupted": "启动未完成",
|
||||
"launchInterruptedTip": "应用上次在启动过程中意外退出。已跳过本次自动配置,你可以手动启动重试。",
|
||||
"clearData": "清除数据",
|
||||
"textScale": "文本缩放",
|
||||
"internet": "互联网",
|
||||
|
||||
+39
-11
@@ -2,6 +2,8 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:fl_clash/common/boot_guard.dart';
|
||||
import 'package:fl_clash/common/boot_record.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/common/launch.dart';
|
||||
import 'package:fl_clash/common/migration.dart';
|
||||
@@ -31,7 +33,7 @@ class Bootstrap {
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
bool _didCrashOnPreviousExecution = false;
|
||||
BootDecision _bootDecision = const BootDecision();
|
||||
|
||||
Future<ProviderContainer> init(int version) async {
|
||||
globalState.appEnv = const String.fromEnvironment(
|
||||
@@ -78,8 +80,11 @@ class Bootstrap {
|
||||
) async {
|
||||
globalState.packageInfo = await PackageInfo.fromPlatform();
|
||||
var config = await migration.run();
|
||||
_didCrashOnPreviousExecution = await system.didCrashOnPreviousExecution();
|
||||
if (_didCrashOnPreviousExecution) {
|
||||
_bootDecision = await bootGuard.evaluate(
|
||||
profileId: config.currentProfileId,
|
||||
crashlyticsEnabled: config.appSettingProps.crashlytics,
|
||||
);
|
||||
if (_bootDecision.recovery == BootRecovery.clearProfile) {
|
||||
config = config.copyWith(currentProfileId: null);
|
||||
await preferences.saveConfig(config);
|
||||
}
|
||||
@@ -148,21 +153,44 @@ class Bootstrap {
|
||||
await _showCrashRecoveryTip();
|
||||
await _showCrashlyticsTip();
|
||||
await _container.read(coreActionProvider.notifier).startCore();
|
||||
if (!_didCrashOnPreviousExecution) {
|
||||
if (!_bootDecision.isDegraded) {
|
||||
await _container.read(setupActionProvider.notifier).initStatus();
|
||||
}
|
||||
_container.read(initProvider.notifier).value = true;
|
||||
await bootGuard.markRunning();
|
||||
permissions.check(_container.read);
|
||||
}
|
||||
|
||||
Future<void> _showCrashRecoveryTip() async {
|
||||
if (!_didCrashOnPreviousExecution) return;
|
||||
await dialogs.showMessage(
|
||||
title: currentAppLocalizations.crashDetected,
|
||||
cancelable: false,
|
||||
dismissible: false,
|
||||
message: TextSpan(text: currentAppLocalizations.crashDetectedTip),
|
||||
);
|
||||
switch (_bootDecision.recovery) {
|
||||
case BootRecovery.none:
|
||||
return;
|
||||
case BootRecovery.skipAutoSetup:
|
||||
await dialogs.showMessage(
|
||||
title: currentAppLocalizations.launchInterrupted,
|
||||
cancelable: false,
|
||||
dismissible: false,
|
||||
message: TextSpan(text: currentAppLocalizations.launchInterruptedTip),
|
||||
);
|
||||
case BootRecovery.clearProfile:
|
||||
await dialogs.showMessage(
|
||||
title: currentAppLocalizations.crashDetected,
|
||||
cancelable: false,
|
||||
dismissible: false,
|
||||
message: TextSpan(
|
||||
text: currentAppLocalizations.crashDetectedTip(_failedProfileLabel),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String get _failedProfileLabel {
|
||||
final profileId = _bootDecision.failedProfileId;
|
||||
if (profileId == null) {
|
||||
return '';
|
||||
}
|
||||
final profile = _container.read(profilesProvider).getProfile(profileId);
|
||||
return profile?.label.takeFirstValid(['$profileId']) ?? '$profileId';
|
||||
}
|
||||
|
||||
Future<void> _handleFailedPreference() async {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:fl_clash/common/boot_record.dart';
|
||||
import 'package:fl_clash/common/preferences.dart';
|
||||
import 'package:fl_clash/common/print.dart';
|
||||
import 'package:fl_clash/common/system.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
|
||||
class BootGuard {
|
||||
final bool _supported;
|
||||
final Future<BootRecord?> Function() _readRecord;
|
||||
final Future<void> Function(BootRecord record) _writeRecord;
|
||||
final Future<AppExitInfo?> Function() _readExitInfo;
|
||||
final Future<bool> Function() _readCrashReport;
|
||||
final int Function() _now;
|
||||
|
||||
BootDecision _decision = const BootDecision();
|
||||
|
||||
BootGuard({
|
||||
bool? supported,
|
||||
Future<BootRecord?> Function()? readRecord,
|
||||
Future<void> Function(BootRecord record)? writeRecord,
|
||||
Future<AppExitInfo?> Function()? readExitInfo,
|
||||
Future<bool> Function()? readCrashReport,
|
||||
int Function()? now,
|
||||
}) : _supported = supported ?? system.isAndroid,
|
||||
_readRecord = readRecord ?? preferences.getBootRecord,
|
||||
_writeRecord = writeRecord ?? preferences.saveBootRecord,
|
||||
_readExitInfo = readExitInfo ?? system.lastExitInfo,
|
||||
_readCrashReport = readCrashReport ?? system.didCrashOnPreviousExecution,
|
||||
_now = now ?? _currentMilliseconds;
|
||||
|
||||
static int _currentMilliseconds() => DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
BootDecision get decision => _decision;
|
||||
|
||||
Future<BootDecision> evaluate({
|
||||
required int? profileId,
|
||||
required bool crashlyticsEnabled,
|
||||
}) async {
|
||||
if (!_supported) {
|
||||
return _decision;
|
||||
}
|
||||
final record = await _readRecord();
|
||||
final exitInfo = await _readExitInfo();
|
||||
final crashReported = crashlyticsEnabled && await _readCrashReport();
|
||||
final decision = resolveBootDecision(
|
||||
record: record,
|
||||
exitInfo: exitInfo,
|
||||
crashReported: crashReported,
|
||||
);
|
||||
if (decision.isDegraded) {
|
||||
commonPrint.log(
|
||||
'Previous launch did not finish: $decision',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
}
|
||||
await _writeRecord(
|
||||
BootRecord(
|
||||
stage: BootStage.starting,
|
||||
profileId: decision.recovery == BootRecovery.clearProfile
|
||||
? null
|
||||
: profileId,
|
||||
startedAt: _now(),
|
||||
failureCount: decision.failureCount,
|
||||
lastFailedProfileId:
|
||||
decision.failedProfileId ?? record?.lastFailedProfileId,
|
||||
handledExitAt: max(
|
||||
record?.handledExitAt ?? 0,
|
||||
exitInfo?.timestamp ?? 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
_decision = decision;
|
||||
return decision;
|
||||
}
|
||||
|
||||
Future<void> markRunning() async {
|
||||
if (!_supported) {
|
||||
return;
|
||||
}
|
||||
final record = await _readRecord();
|
||||
if (record == null) {
|
||||
return;
|
||||
}
|
||||
await _writeRecord(
|
||||
BootRecord(
|
||||
stage: BootStage.running,
|
||||
profileId: record.profileId,
|
||||
startedAt: record.startedAt,
|
||||
failureCount: _decision.isDegraded ? record.failureCount : 0,
|
||||
lastFailedProfileId: record.lastFailedProfileId,
|
||||
handledExitAt: record.handledExitAt,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markClosed() async {
|
||||
if (!_supported) {
|
||||
return;
|
||||
}
|
||||
final record = await _readRecord();
|
||||
if (record == null) {
|
||||
return;
|
||||
}
|
||||
await _writeRecord(
|
||||
BootRecord(
|
||||
profileId: record.profileId,
|
||||
startedAt: record.startedAt,
|
||||
lastFailedProfileId: record.lastFailedProfileId,
|
||||
handledExitAt: record.handledExitAt,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final bootGuard = BootGuard();
|
||||
@@ -0,0 +1,225 @@
|
||||
const crashRecoveryClearThreshold = 2;
|
||||
|
||||
enum AppExitReason {
|
||||
unknown,
|
||||
exitSelf,
|
||||
signaled,
|
||||
lowMemory,
|
||||
crash,
|
||||
crashNative,
|
||||
anr,
|
||||
initializationFailure,
|
||||
permissionChange,
|
||||
excessiveResourceUsage,
|
||||
userRequested,
|
||||
userStopped,
|
||||
dependencyDied,
|
||||
other,
|
||||
freezer,
|
||||
packageStateChange,
|
||||
packageUpdated;
|
||||
|
||||
static AppExitReason fromCode(Object? code) => switch (code) {
|
||||
1 => exitSelf,
|
||||
2 => signaled,
|
||||
3 => lowMemory,
|
||||
4 => crash,
|
||||
5 => crashNative,
|
||||
6 => anr,
|
||||
7 => initializationFailure,
|
||||
8 => permissionChange,
|
||||
9 => excessiveResourceUsage,
|
||||
10 => userRequested,
|
||||
11 => userStopped,
|
||||
12 => dependencyDied,
|
||||
13 => other,
|
||||
14 => freezer,
|
||||
15 => packageStateChange,
|
||||
16 => packageUpdated,
|
||||
_ => unknown,
|
||||
};
|
||||
|
||||
bool get isCrash => switch (this) {
|
||||
crash || crashNative || anr || initializationFailure => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
bool get isExternalStop => switch (this) {
|
||||
exitSelf ||
|
||||
signaled ||
|
||||
lowMemory ||
|
||||
permissionChange ||
|
||||
excessiveResourceUsage ||
|
||||
userRequested ||
|
||||
userStopped ||
|
||||
dependencyDied ||
|
||||
freezer ||
|
||||
packageStateChange ||
|
||||
packageUpdated => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
class AppExitInfo {
|
||||
final AppExitReason reason;
|
||||
final int timestamp;
|
||||
final String? description;
|
||||
|
||||
const AppExitInfo({
|
||||
required this.reason,
|
||||
required this.timestamp,
|
||||
this.description,
|
||||
});
|
||||
|
||||
static AppExitInfo? fromJson(Object? json) {
|
||||
if (json is! Map) {
|
||||
return null;
|
||||
}
|
||||
final timestamp = json['timestamp'];
|
||||
if (timestamp is! int) {
|
||||
return null;
|
||||
}
|
||||
final description = json['description'];
|
||||
return AppExitInfo(
|
||||
reason: AppExitReason.fromCode(json['reason']),
|
||||
timestamp: timestamp,
|
||||
description: description is String ? description : null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AppExitInfo(${reason.name}, $timestamp, ${description ?? '-'})';
|
||||
}
|
||||
|
||||
enum BootStage { starting, running }
|
||||
|
||||
class BootRecord {
|
||||
final BootStage? stage;
|
||||
final int? profileId;
|
||||
final int startedAt;
|
||||
final int failureCount;
|
||||
final int? lastFailedProfileId;
|
||||
final int handledExitAt;
|
||||
|
||||
const BootRecord({
|
||||
this.stage,
|
||||
this.profileId,
|
||||
this.startedAt = 0,
|
||||
this.failureCount = 0,
|
||||
this.lastFailedProfileId,
|
||||
this.handledExitAt = 0,
|
||||
});
|
||||
|
||||
static BootRecord? fromJson(Object? json) {
|
||||
if (json is! Map) {
|
||||
return null;
|
||||
}
|
||||
final stage = json['stage'];
|
||||
return BootRecord(
|
||||
stage: BootStage.values.where((value) => value.name == stage).firstOrNull,
|
||||
profileId: json['profileId'] is int ? json['profileId'] as int : null,
|
||||
startedAt: json['startedAt'] is int ? json['startedAt'] as int : 0,
|
||||
failureCount: json['failureCount'] is int
|
||||
? json['failureCount'] as int
|
||||
: 0,
|
||||
lastFailedProfileId: json['lastFailedProfileId'] is int
|
||||
? json['lastFailedProfileId'] as int
|
||||
: null,
|
||||
handledExitAt: json['handledExitAt'] is int
|
||||
? json['handledExitAt'] as int
|
||||
: 0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'stage': stage?.name,
|
||||
'profileId': profileId,
|
||||
'startedAt': startedAt,
|
||||
'failureCount': failureCount,
|
||||
'lastFailedProfileId': lastFailedProfileId,
|
||||
'handledExitAt': handledExitAt,
|
||||
};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is BootRecord &&
|
||||
other.stage == stage &&
|
||||
other.profileId == profileId &&
|
||||
other.startedAt == startedAt &&
|
||||
other.failureCount == failureCount &&
|
||||
other.lastFailedProfileId == lastFailedProfileId &&
|
||||
other.handledExitAt == handledExitAt;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
stage,
|
||||
profileId,
|
||||
startedAt,
|
||||
failureCount,
|
||||
lastFailedProfileId,
|
||||
handledExitAt,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'BootRecord(${stage?.name ?? 'closed'}, profile: $profileId, '
|
||||
'failures: $failureCount)';
|
||||
}
|
||||
|
||||
enum BootRecovery { none, skipAutoSetup, clearProfile }
|
||||
|
||||
class BootDecision {
|
||||
final BootRecovery recovery;
|
||||
final int failureCount;
|
||||
final int? failedProfileId;
|
||||
final bool crashConfirmed;
|
||||
final AppExitReason? exitReason;
|
||||
|
||||
const BootDecision({
|
||||
this.recovery = BootRecovery.none,
|
||||
this.failureCount = 0,
|
||||
this.failedProfileId,
|
||||
this.crashConfirmed = false,
|
||||
this.exitReason,
|
||||
});
|
||||
|
||||
bool get isDegraded => recovery != BootRecovery.none;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'BootDecision(${recovery.name}, failures: $failureCount, '
|
||||
'profile: $failedProfileId, confirmed: $crashConfirmed, '
|
||||
'exit: ${exitReason?.name ?? '-'})';
|
||||
}
|
||||
|
||||
BootDecision resolveBootDecision({
|
||||
required BootRecord? record,
|
||||
required AppExitInfo? exitInfo,
|
||||
required bool crashReported,
|
||||
}) {
|
||||
if (record == null || record.stage != BootStage.starting) {
|
||||
return const BootDecision();
|
||||
}
|
||||
final matchesLastRun =
|
||||
exitInfo != null &&
|
||||
exitInfo.timestamp > record.handledExitAt &&
|
||||
exitInfo.timestamp >= record.startedAt;
|
||||
final reason = matchesLastRun ? exitInfo.reason : null;
|
||||
if (reason != null && reason.isExternalStop) {
|
||||
return BootDecision(exitReason: reason);
|
||||
}
|
||||
final failureCount = record.failureCount + 1;
|
||||
final shouldClear =
|
||||
failureCount >= crashRecoveryClearThreshold && record.profileId != null;
|
||||
return BootDecision(
|
||||
recovery: shouldClear
|
||||
? BootRecovery.clearProfile
|
||||
: BootRecovery.skipAutoSetup,
|
||||
failureCount: failureCount,
|
||||
failedProfileId: record.profileId,
|
||||
crashConfirmed: (reason?.isCrash ?? false) || crashReported,
|
||||
exitReason: reason,
|
||||
);
|
||||
}
|
||||
@@ -65,16 +65,16 @@ const MMDB = 'GEOIP.metadb';
|
||||
const ASN = 'ASN.mmdb';
|
||||
const GEOIP = 'GEOIP.dat';
|
||||
const GEOSITE = 'GEOSITE.dat';
|
||||
final double kHeaderHeight = system.isDesktop
|
||||
? !system.isMacOS
|
||||
? 40
|
||||
: 28
|
||||
: 0;
|
||||
final double kHeaderHeight = getWindowHeaderHeight(
|
||||
isDesktop: system.isDesktop,
|
||||
isMacOS: system.isMacOS,
|
||||
);
|
||||
const profilesDirectoryName = 'profiles';
|
||||
const localhost = '127.0.0.1';
|
||||
const clashConfigKey = 'clash_config';
|
||||
const configKey = 'config';
|
||||
const systemDnsRecordKey = 'system_dns_record';
|
||||
const bootRecordKey = 'boot_record';
|
||||
const defaultSystemDnsFallback = '223.5.5.5';
|
||||
const double dialogCommonWidth = 300;
|
||||
const repository = 'chen08209/FlClash';
|
||||
|
||||
@@ -4,6 +4,21 @@ import 'package:fl_clash/enum/enum.dart';
|
||||
|
||||
import 'constant.dart';
|
||||
|
||||
double getWindowHeaderHeight({required bool isDesktop, required bool isMacOS}) {
|
||||
if (!isDesktop) return 0;
|
||||
return isMacOS ? 28 : 40;
|
||||
}
|
||||
|
||||
bool showsWindowHeader({
|
||||
required bool isDesktop,
|
||||
required bool isMacOS,
|
||||
required int version,
|
||||
required bool isMobileView,
|
||||
}) {
|
||||
if (!isDesktop) return false;
|
||||
return !(isMacOS && (version <= 10 || !isMobileView));
|
||||
}
|
||||
|
||||
ViewMode getViewMode(double viewWidth) {
|
||||
if (viewWidth <= maxMobileWidth) return ViewMode.mobile;
|
||||
if (viewWidth <= maxLaptopWidth) return ViewMode.laptop;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fl_clash/common/boot_record.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/common/system_dns.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
@@ -128,6 +129,28 @@ class Preferences {
|
||||
await sharedPreferencesIns?.remove(systemDnsRecordKey);
|
||||
}
|
||||
|
||||
Future<BootRecord?> getBootRecord() async {
|
||||
try {
|
||||
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
|
||||
final raw = sharedPreferencesIns?.getString(bootRecordKey);
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
return BootRecord.fromJson(json.decode(raw));
|
||||
} catch (e) {
|
||||
commonPrint.log(
|
||||
'getBootRecord error ${e.toString()}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveBootRecord(BootRecord record) async {
|
||||
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
|
||||
await sharedPreferencesIns?.setString(bootRecordKey, json.encode(record));
|
||||
}
|
||||
|
||||
Future<void> clearPreferences() async {
|
||||
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
|
||||
await sharedPreferencesIns?.clear();
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:fl_clash/common/boot_record.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/common/system_dns.dart';
|
||||
import 'package:fl_clash/core/desktop/helper_client.dart';
|
||||
@@ -65,6 +66,11 @@ class System {
|
||||
return await app?.didCrashOnPreviousExecution() ?? false;
|
||||
}
|
||||
|
||||
Future<AppExitInfo?> lastExitInfo() async {
|
||||
if (!isAndroid) return null;
|
||||
return app?.getLastExitInfo();
|
||||
}
|
||||
|
||||
/// Arguments for the `stat` call behind [checkIsAdmin].
|
||||
///
|
||||
/// [corePath] is handed to `Process.run` as an argv entry, so it must stay
|
||||
|
||||
+24
-9
@@ -1,6 +1,7 @@
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:tray/tray.dart';
|
||||
@@ -15,21 +16,35 @@ import 'window.dart';
|
||||
class AppTray implements TrayPort {
|
||||
static AppTray? _instance;
|
||||
|
||||
final bool isMacOS;
|
||||
final bool isWindows;
|
||||
|
||||
bool _isShutDown = false;
|
||||
|
||||
AppTray._internal();
|
||||
AppTray._internal({required this.isMacOS, required this.isWindows});
|
||||
|
||||
factory AppTray() {
|
||||
_instance ??= AppTray._internal();
|
||||
_instance ??= AppTray._internal(
|
||||
isMacOS: system.isMacOS,
|
||||
isWindows: system.isWindows,
|
||||
);
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
factory AppTray.forPlatform({
|
||||
required bool isMacOS,
|
||||
required bool isWindows,
|
||||
}) {
|
||||
return AppTray._internal(isMacOS: isMacOS, isWindows: isWindows);
|
||||
}
|
||||
|
||||
String get trayIconSuffix {
|
||||
return system.isWindows ? 'ico' : 'png';
|
||||
return isWindows ? 'ico' : 'png';
|
||||
}
|
||||
|
||||
String getTrayIcon({required bool isStart, required bool tunEnable}) {
|
||||
if (system.isMacOS || !isStart) {
|
||||
if (isMacOS || !isStart) {
|
||||
return 'assets/images/icon/status_1.$trayIconSuffix';
|
||||
}
|
||||
if (!tunEnable) {
|
||||
@@ -60,7 +75,7 @@ class AppTray implements TrayPort {
|
||||
isStart: trayState.isStart,
|
||||
tunEnable: trayState.tunEnable,
|
||||
),
|
||||
isTemplate: system.isMacOS,
|
||||
isTemplate: isMacOS,
|
||||
),
|
||||
toolTip: appName,
|
||||
menu: _buildMenu(trayState: trayState, read: read),
|
||||
@@ -73,7 +88,7 @@ class AppTray implements TrayPort {
|
||||
required bool showTrayTitle,
|
||||
required Traffic traffic,
|
||||
}) async {
|
||||
if (_isShutDown || !system.isMacOS) {
|
||||
if (_isShutDown || !isMacOS) {
|
||||
return;
|
||||
}
|
||||
await Tray.instance.setTitle(showTrayTitle ? traffic.trayTitle : '');
|
||||
@@ -102,7 +117,7 @@ class AppTray implements TrayPort {
|
||||
checked: false,
|
||||
onSelected: commonAction.toggleRunning,
|
||||
),
|
||||
if (system.isMacOS)
|
||||
if (isMacOS)
|
||||
TrayMenuCheckbox(
|
||||
label: appLocalizations.speedStatistics,
|
||||
checked: trayState.showTrayTitle,
|
||||
@@ -118,7 +133,7 @@ class AppTray implements TrayPort {
|
||||
},
|
||||
),
|
||||
const TrayMenuSeparator(),
|
||||
if (system.isMacOS) ..._buildGroupMenu(trayState: trayState, read: read),
|
||||
if (isMacOS) ..._buildGroupMenu(trayState: trayState, read: read),
|
||||
if (trayState.isStart) ...[
|
||||
TrayMenuCheckbox(
|
||||
label: appLocalizations.tun,
|
||||
@@ -185,7 +200,7 @@ class AppTray implements TrayPort {
|
||||
Future<void> _copyEnv(int port) async {
|
||||
final url = 'http://127.0.0.1:$port';
|
||||
|
||||
final cmdline = system.isWindows
|
||||
final cmdline = isWindows
|
||||
? 'set \$env:all_proxy=$url'
|
||||
: 'export all_proxy=$url';
|
||||
|
||||
|
||||
@@ -20,69 +20,72 @@ typedef String MessageIfAbsent(String messageStr, List<dynamic> args);
|
||||
class MessageLookup extends MessageLookupByLibrary {
|
||||
String get localeName => 'en';
|
||||
|
||||
static String m0(count) =>
|
||||
static String m0(name) =>
|
||||
"The app failed to finish launching twice in a row. To break the loop, the profile ${name} has been deselected and automatic setup was skipped. You can select it again at any time.";
|
||||
|
||||
static String m1(count) =>
|
||||
"${Intl.plural(count, one: '1 day ago', other: '${count} days ago')}";
|
||||
|
||||
static String m1(label) =>
|
||||
static String m2(label) =>
|
||||
"Are you sure you want to delete the selected ${label}?";
|
||||
|
||||
static String m2(label) =>
|
||||
static String m3(label) =>
|
||||
"Are you sure you want to delete the current ${label}?";
|
||||
|
||||
static String m3(label) => "${label} details";
|
||||
static String m4(label) => "${label} details";
|
||||
|
||||
static String m4(label) => "${label} cannot be empty";
|
||||
static String m5(label) => "${label} cannot be empty";
|
||||
|
||||
static String m5(count) => "${count} entries";
|
||||
static String m6(count) => "${count} entries";
|
||||
|
||||
static String m6(label) => "Current ${label} already exists";
|
||||
static String m7(label) => "Current ${label} already exists";
|
||||
|
||||
static String m7(name) => "${name} is already up to date";
|
||||
static String m8(name) => "${name} is already up to date";
|
||||
|
||||
static String m8(name) => "${name} updated";
|
||||
static String m9(name) => "${name} updated";
|
||||
|
||||
static String m9(count) =>
|
||||
static String m10(count) =>
|
||||
"${Intl.plural(count, one: '1 hour ago', other: '${count} hours ago')}";
|
||||
|
||||
static String m10(count) => "${count} hours";
|
||||
static String m11(count) => "${count} hours";
|
||||
|
||||
static String m11(target) => "${target} is an invalid policy";
|
||||
static String m12(target) => "${target} is an invalid policy";
|
||||
|
||||
static String m12(proxyName) => "${proxyName} is an invalid proxy";
|
||||
static String m13(proxyName) => "${proxyName} is an invalid proxy";
|
||||
|
||||
static String m13(providerName) =>
|
||||
static String m14(providerName) =>
|
||||
"${providerName} is an invalid proxy provider";
|
||||
|
||||
static String m14(subRule) => "${subRule} is an invalid SUB_RULE";
|
||||
static String m15(subRule) => "${subRule} is an invalid SUB_RULE";
|
||||
|
||||
static String m15(appName) =>
|
||||
static String m16(appName) =>
|
||||
"1. Open System Settings > Privacy & Security\n2. Choose Location Services\n3. Find and check ${appName} in the right list\n\nAfter completing the setup, return to the app and use it normally. Thank you for your cooperation.";
|
||||
|
||||
static String m16(count) =>
|
||||
static String m17(count) =>
|
||||
"${Intl.plural(count, one: '1 minute ago', other: '${count} minutes ago')}";
|
||||
|
||||
static String m17(count) =>
|
||||
static String m18(count) =>
|
||||
"${Intl.plural(count, one: '1 month ago', other: '${count} months ago')}";
|
||||
|
||||
static String m18(label) => "No ${label} yet";
|
||||
static String m19(label) => "No ${label} yet";
|
||||
|
||||
static String m19(label) => "${label} must be a number";
|
||||
static String m20(label) => "${label} must be a number";
|
||||
|
||||
static String m20(label) => "${label} must be between 1024 and 49151";
|
||||
|
||||
static String m21(count) =>
|
||||
"${Intl.plural(count, one: '1 proxy', other: '${count} proxies')}";
|
||||
static String m21(label) => "${label} must be between 1024 and 49151";
|
||||
|
||||
static String m22(count) =>
|
||||
"${Intl.plural(count, one: '1 proxy', other: '${count} proxies')}";
|
||||
|
||||
static String m23(count) =>
|
||||
"${Intl.plural(count, one: '1 rule', other: '${count} rules')}";
|
||||
|
||||
static String m23(count) => "${count} seconds";
|
||||
static String m24(count) => "${count} seconds";
|
||||
|
||||
static String m24(count) => "${count} items have been selected";
|
||||
static String m25(count) => "${count} items have been selected";
|
||||
|
||||
static String m25(label) => "${label} must be a url";
|
||||
static String m26(label) => "${label} must be a url";
|
||||
|
||||
static String m26(count) =>
|
||||
static String m27(count) =>
|
||||
"${Intl.plural(count, one: '1 year ago', other: '${count} years ago')}";
|
||||
|
||||
final messages = _notInlinedMessages(_notInlinedMessages);
|
||||
@@ -298,9 +301,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"coreStatus": MessageLookupByLibrary.simpleMessage("Core status"),
|
||||
"country": MessageLookupByLibrary.simpleMessage("Country"),
|
||||
"crashDetected": MessageLookupByLibrary.simpleMessage("Crash detected"),
|
||||
"crashDetectedTip": MessageLookupByLibrary.simpleMessage(
|
||||
"The app crashed during the previous run. To prevent repeated crashes, the current profile has been cleared and automatic configuration setup was skipped.",
|
||||
),
|
||||
"crashDetectedTip": m0,
|
||||
"crashTest": MessageLookupByLibrary.simpleMessage("Crash test"),
|
||||
"crashlytics": MessageLookupByLibrary.simpleMessage("Crash Analysis"),
|
||||
"crashlyticsTip": MessageLookupByLibrary.simpleMessage(
|
||||
@@ -325,7 +326,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"databaseWriteFailedTip": MessageLookupByLibrary.simpleMessage(
|
||||
"Failed to save the change, it has been rolled back",
|
||||
),
|
||||
"daysAgo": m0,
|
||||
"daysAgo": m1,
|
||||
"defaultNameserver": MessageLookupByLibrary.simpleMessage(
|
||||
"Default nameserver",
|
||||
),
|
||||
@@ -336,8 +337,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"delay": MessageLookupByLibrary.simpleMessage("Delay"),
|
||||
"delayTest": MessageLookupByLibrary.simpleMessage("Delay Test"),
|
||||
"delete": MessageLookupByLibrary.simpleMessage("Delete"),
|
||||
"deleteMultipTip": m1,
|
||||
"deleteTip": m2,
|
||||
"deleteMultipTip": m2,
|
||||
"deleteTip": m3,
|
||||
"desc": MessageLookupByLibrary.simpleMessage(
|
||||
"A multi-platform proxy client based on ClashMeta, simple and easy to use, open-source and ad-free.",
|
||||
),
|
||||
@@ -348,7 +349,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"destinationIPASN": MessageLookupByLibrary.simpleMessage(
|
||||
"Destination IPASN",
|
||||
),
|
||||
"details": m3,
|
||||
"details": m4,
|
||||
"detectionTip": MessageLookupByLibrary.simpleMessage(
|
||||
"Relying on third-party api is for reference only",
|
||||
),
|
||||
@@ -384,10 +385,10 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"editProxyGroup": MessageLookupByLibrary.simpleMessage("Edit proxy group"),
|
||||
"editRule": MessageLookupByLibrary.simpleMessage("Edit rule"),
|
||||
"editSsid": MessageLookupByLibrary.simpleMessage("Edit SSID"),
|
||||
"emptyTip": m4,
|
||||
"emptyTip": m5,
|
||||
"en": MessageLookupByLibrary.simpleMessage("English"),
|
||||
"entries": MessageLookupByLibrary.simpleMessage(" entries"),
|
||||
"entriesCount": m5,
|
||||
"entriesCount": m6,
|
||||
"exclude": MessageLookupByLibrary.simpleMessage("Hidden from recent tasks"),
|
||||
"excludeDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"When the app is in the background, the app is hidden from the recent task",
|
||||
@@ -400,7 +401,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"When connected to an excluded SSID Wi-Fi, the app running state will be automatically switched.",
|
||||
),
|
||||
"excludeType": MessageLookupByLibrary.simpleMessage("Exclude type"),
|
||||
"existsTip": m6,
|
||||
"existsTip": m7,
|
||||
"exit": MessageLookupByLibrary.simpleMessage("Exit"),
|
||||
"expand": MessageLookupByLibrary.simpleMessage("Standard"),
|
||||
"expectedStatus": MessageLookupByLibrary.simpleMessage("Expected status"),
|
||||
@@ -449,8 +450,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
),
|
||||
"geoOptions": MessageLookupByLibrary.simpleMessage("Geo Options"),
|
||||
"geoResources": MessageLookupByLibrary.simpleMessage("Geo Resources"),
|
||||
"geoSkipped": m7,
|
||||
"geoUpdated": m8,
|
||||
"geoSkipped": m8,
|
||||
"geoUpdated": m9,
|
||||
"geodataLoader": MessageLookupByLibrary.simpleMessage(
|
||||
"Geo Low Memory Mode",
|
||||
),
|
||||
@@ -482,8 +483,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Use keyboard to control applications",
|
||||
),
|
||||
"hours": MessageLookupByLibrary.simpleMessage("hours"),
|
||||
"hoursAgo": m9,
|
||||
"hoursCount": m10,
|
||||
"hoursAgo": m10,
|
||||
"hoursCount": m11,
|
||||
"icon": MessageLookupByLibrary.simpleMessage("Icon"),
|
||||
"iconRecords": MessageLookupByLibrary.simpleMessage("Icon records"),
|
||||
"iconStyle": MessageLookupByLibrary.simpleMessage("Icon style"),
|
||||
@@ -527,10 +528,10 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"invalidBackupFile": MessageLookupByLibrary.simpleMessage(
|
||||
"Invalid backup file",
|
||||
),
|
||||
"invalidPolicy": m11,
|
||||
"invalidProxy": m12,
|
||||
"invalidProxyProvider": m13,
|
||||
"invalidSubRule": m14,
|
||||
"invalidPolicy": m12,
|
||||
"invalidProxy": m13,
|
||||
"invalidProxyProvider": m14,
|
||||
"invalidSubRule": m15,
|
||||
"ipcidr": MessageLookupByLibrary.simpleMessage("Ipcidr"),
|
||||
"ipv6Desc": MessageLookupByLibrary.simpleMessage(
|
||||
"When turned on it will be able to receive IPv6 traffic",
|
||||
@@ -545,6 +546,12 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
),
|
||||
"key": MessageLookupByLibrary.simpleMessage("Key"),
|
||||
"language": MessageLookupByLibrary.simpleMessage("Language"),
|
||||
"launchInterrupted": MessageLookupByLibrary.simpleMessage(
|
||||
"Launch did not finish",
|
||||
),
|
||||
"launchInterruptedTip": MessageLookupByLibrary.simpleMessage(
|
||||
"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.",
|
||||
),
|
||||
"layout": MessageLookupByLibrary.simpleMessage("Layout"),
|
||||
"light": MessageLookupByLibrary.simpleMessage("Light"),
|
||||
"list": MessageLookupByLibrary.simpleMessage("List"),
|
||||
@@ -563,7 +570,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"locationPermissionDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"According to system requirements, obtaining the Wi-Fi name requires you to grant location permission.",
|
||||
),
|
||||
"locationPermissionGuide": m15,
|
||||
"locationPermissionGuide": m16,
|
||||
"locationPermissionRequired": MessageLookupByLibrary.simpleMessage(
|
||||
"Location Permission Required",
|
||||
),
|
||||
@@ -595,11 +602,11 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"minimizeOnExitDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"Modify the default system exit event",
|
||||
),
|
||||
"minutesAgo": m16,
|
||||
"minutesAgo": m17,
|
||||
"mixedPort": MessageLookupByLibrary.simpleMessage("Mixed Port"),
|
||||
"mode": MessageLookupByLibrary.simpleMessage("Mode"),
|
||||
"monochromeScheme": MessageLookupByLibrary.simpleMessage("Monochrome"),
|
||||
"monthsAgo": m17,
|
||||
"monthsAgo": m18,
|
||||
"more": MessageLookupByLibrary.simpleMessage("More"),
|
||||
"name": MessageLookupByLibrary.simpleMessage("Name"),
|
||||
"nameserver": MessageLookupByLibrary.simpleMessage("Nameserver"),
|
||||
@@ -646,8 +653,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"nullProfileDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"No profile, Please add a profile",
|
||||
),
|
||||
"nullTip": m18,
|
||||
"numberTip": m19,
|
||||
"nullTip": m19,
|
||||
"numberTip": m20,
|
||||
"onDemand": MessageLookupByLibrary.simpleMessage("On Demand"),
|
||||
"onDemandDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"Configure the program running state for specific scenarios",
|
||||
@@ -698,7 +705,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"portConflictTip": MessageLookupByLibrary.simpleMessage(
|
||||
"Please enter a different port",
|
||||
),
|
||||
"portTip": m20,
|
||||
"portTip": m21,
|
||||
"preferH3Desc": MessageLookupByLibrary.simpleMessage(
|
||||
"Prioritize the use of DOH\'s http/3",
|
||||
),
|
||||
@@ -735,7 +742,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"project": MessageLookupByLibrary.simpleMessage("Project"),
|
||||
"providers": MessageLookupByLibrary.simpleMessage("Providers"),
|
||||
"proxies": MessageLookupByLibrary.simpleMessage("Proxies"),
|
||||
"proxiesCount": m21,
|
||||
"proxiesCount": m22,
|
||||
"proxiesEmpty": MessageLookupByLibrary.simpleMessage("Proxies is empty"),
|
||||
"proxyChains": MessageLookupByLibrary.simpleMessage("Proxy chains"),
|
||||
"proxyDetectedAbnormal": MessageLookupByLibrary.simpleMessage(
|
||||
@@ -945,7 +952,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"ruleSet": MessageLookupByLibrary.simpleMessage("Rule set"),
|
||||
"ruleTarget": MessageLookupByLibrary.simpleMessage("Rule target"),
|
||||
"rules": MessageLookupByLibrary.simpleMessage("Rules"),
|
||||
"rulesCount": m22,
|
||||
"rulesCount": m23,
|
||||
"save": MessageLookupByLibrary.simpleMessage("Save"),
|
||||
"saveChanges": MessageLookupByLibrary.simpleMessage(
|
||||
"Do you want to save the changes?",
|
||||
@@ -959,7 +966,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
),
|
||||
"search": MessageLookupByLibrary.simpleMessage("Search"),
|
||||
"seconds": MessageLookupByLibrary.simpleMessage("Seconds"),
|
||||
"secondsCount": m23,
|
||||
"secondsCount": m24,
|
||||
"selectAll": MessageLookupByLibrary.simpleMessage("Select all"),
|
||||
"selectProxies": MessageLookupByLibrary.simpleMessage("Select proxies"),
|
||||
"selectProxyProviders": MessageLookupByLibrary.simpleMessage(
|
||||
@@ -975,7 +982,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Please select sub rule",
|
||||
),
|
||||
"selected": MessageLookupByLibrary.simpleMessage("Selected"),
|
||||
"selectedCountTitle": m24,
|
||||
"selectedCountTitle": m25,
|
||||
"settings": MessageLookupByLibrary.simpleMessage("Settings"),
|
||||
"show": MessageLookupByLibrary.simpleMessage("Show"),
|
||||
"showLess": MessageLookupByLibrary.simpleMessage("Show less"),
|
||||
@@ -1086,7 +1093,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"urlDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"Obtain profile through URL",
|
||||
),
|
||||
"urlTip": m25,
|
||||
"urlTip": m26,
|
||||
"useHosts": MessageLookupByLibrary.simpleMessage("Use hosts"),
|
||||
"useSystemHosts": MessageLookupByLibrary.simpleMessage("Use system hosts"),
|
||||
"usedTraffic": MessageLookupByLibrary.simpleMessage("Used traffic"),
|
||||
@@ -1107,7 +1114,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"WebDAV configuration",
|
||||
),
|
||||
"whitelistMode": MessageLookupByLibrary.simpleMessage("Whitelist mode"),
|
||||
"yearsAgo": m26,
|
||||
"yearsAgo": m27,
|
||||
"zh_CN": MessageLookupByLibrary.simpleMessage("Simplified Chinese"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,60 +20,63 @@ typedef String MessageIfAbsent(String messageStr, List<dynamic> args);
|
||||
class MessageLookup extends MessageLookupByLibrary {
|
||||
String get localeName => 'ja';
|
||||
|
||||
static String m0(count) => "${count}日前";
|
||||
static String m0(name) =>
|
||||
"アプリの起動が 2 回連続で完了しませんでした。繰り返しを断ち切るため、プロファイル ${name} の選択を解除し、設定の自動セットアップをスキップしました。いつでも選択し直せます。";
|
||||
|
||||
static String m1(label) => "選択された${label}を削除してもよろしいですか?";
|
||||
static String m1(count) => "${count}日前";
|
||||
|
||||
static String m2(label) => "現在の${label}を削除してもよろしいですか?";
|
||||
static String m2(label) => "選択された${label}を削除してもよろしいですか?";
|
||||
|
||||
static String m3(label) => "${label}詳細";
|
||||
static String m3(label) => "現在の${label}を削除してもよろしいですか?";
|
||||
|
||||
static String m4(label) => "${label}は空欄にできません";
|
||||
static String m4(label) => "${label}詳細";
|
||||
|
||||
static String m5(count) => "${count} エントリ";
|
||||
static String m5(label) => "${label}は空欄にできません";
|
||||
|
||||
static String m6(label) => "現在の${label}は既に存在しています";
|
||||
static String m6(count) => "${count} エントリ";
|
||||
|
||||
static String m7(name) => "${name} はすでに最新です";
|
||||
static String m7(label) => "現在の${label}は既に存在しています";
|
||||
|
||||
static String m8(name) => "${name} 更新済み";
|
||||
static String m8(name) => "${name} はすでに最新です";
|
||||
|
||||
static String m9(count) => "${count}時間前";
|
||||
static String m9(name) => "${name} 更新済み";
|
||||
|
||||
static String m10(count) => "${count} 時間";
|
||||
static String m10(count) => "${count}時間前";
|
||||
|
||||
static String m11(target) => "${target} は無効なポリシーです";
|
||||
static String m11(count) => "${count} 時間";
|
||||
|
||||
static String m12(proxyName) => "${proxyName} は無効なプロキシです";
|
||||
static String m12(target) => "${target} は無効なポリシーです";
|
||||
|
||||
static String m13(providerName) => "${providerName} は無効なプロキシプロバイダーです";
|
||||
static String m13(proxyName) => "${proxyName} は無効なプロキシです";
|
||||
|
||||
static String m14(subRule) => "${subRule} は無効なSUB_RULEです";
|
||||
static String m14(providerName) => "${providerName} は無効なプロキシプロバイダーです";
|
||||
|
||||
static String m15(appName) =>
|
||||
static String m15(subRule) => "${subRule} は無効なSUB_RULEです";
|
||||
|
||||
static String m16(appName) =>
|
||||
"1. システム設定 > プライバシーとセキュリティ を開く\n2. 位置情報サービス を選択\n3. 右側のリストで ${appName} を見つけてチェックする\n\n設定が完了したらアプリに戻ると通常どおり使用できます。ご協力ありがとうございます。";
|
||||
|
||||
static String m16(count) => "${count}分前";
|
||||
static String m17(count) => "${count}分前";
|
||||
|
||||
static String m17(count) => "${count}ヶ月前";
|
||||
static String m18(count) => "${count}ヶ月前";
|
||||
|
||||
static String m18(label) => "まだ${label}はありません";
|
||||
static String m19(label) => "まだ${label}はありません";
|
||||
|
||||
static String m19(label) => "${label}は数字でなければなりません";
|
||||
static String m20(label) => "${label}は数字でなければなりません";
|
||||
|
||||
static String m20(label) => "${label} は 1024 から 49151 の間でなければなりません";
|
||||
static String m21(label) => "${label} は 1024 から 49151 の間でなければなりません";
|
||||
|
||||
static String m21(count) => "${count} プロキシ";
|
||||
static String m22(count) => "${count} プロキシ";
|
||||
|
||||
static String m22(count) => "${count} ルール";
|
||||
static String m23(count) => "${count} ルール";
|
||||
|
||||
static String m23(count) => "${count} 秒";
|
||||
static String m24(count) => "${count} 秒";
|
||||
|
||||
static String m24(count) => "${count} 項目が選択されています";
|
||||
static String m25(count) => "${count} 項目が選択されています";
|
||||
|
||||
static String m25(label) => "${label}はURLである必要があります";
|
||||
static String m26(label) => "${label}はURLである必要があります";
|
||||
|
||||
static String m26(count) => "${count}年前";
|
||||
static String m27(count) => "${count}年前";
|
||||
|
||||
final messages = _notInlinedMessages(_notInlinedMessages);
|
||||
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
|
||||
@@ -232,9 +235,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"coreStatus": MessageLookupByLibrary.simpleMessage("コアステータス"),
|
||||
"country": MessageLookupByLibrary.simpleMessage("国"),
|
||||
"crashDetected": MessageLookupByLibrary.simpleMessage("クラッシュを検出しました"),
|
||||
"crashDetectedTip": MessageLookupByLibrary.simpleMessage(
|
||||
"前回の実行中にアプリがクラッシュしました。クラッシュの繰り返しを防ぐため、現在のプロファイルを解除し、設定の自動セットアップをスキップしました。",
|
||||
),
|
||||
"crashDetectedTip": m0,
|
||||
"crashTest": MessageLookupByLibrary.simpleMessage("クラッシュテスト"),
|
||||
"crashlytics": MessageLookupByLibrary.simpleMessage("クラッシュ分析"),
|
||||
"crashlyticsTip": MessageLookupByLibrary.simpleMessage(
|
||||
@@ -257,7 +258,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"databaseWriteFailedTip": MessageLookupByLibrary.simpleMessage(
|
||||
"変更の保存に失敗したため、元に戻しました",
|
||||
),
|
||||
"daysAgo": m0,
|
||||
"daysAgo": m1,
|
||||
"defaultNameserver": MessageLookupByLibrary.simpleMessage("デフォルトネームサーバー"),
|
||||
"defaultNameserverDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"DNSサーバーの解決用",
|
||||
@@ -266,15 +267,15 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"delay": MessageLookupByLibrary.simpleMessage("遅延"),
|
||||
"delayTest": MessageLookupByLibrary.simpleMessage("遅延テスト"),
|
||||
"delete": MessageLookupByLibrary.simpleMessage("削除"),
|
||||
"deleteMultipTip": m1,
|
||||
"deleteTip": m2,
|
||||
"deleteMultipTip": m2,
|
||||
"deleteTip": m3,
|
||||
"desc": MessageLookupByLibrary.simpleMessage(
|
||||
"ClashMetaベースのマルチプラットフォームプロキシクライアント。シンプルで使いやすく、オープンソースで広告なし。",
|
||||
),
|
||||
"destination": MessageLookupByLibrary.simpleMessage("宛先"),
|
||||
"destinationGeoIP": MessageLookupByLibrary.simpleMessage("宛先地理情報"),
|
||||
"destinationIPASN": MessageLookupByLibrary.simpleMessage("宛先IP ASN"),
|
||||
"details": m3,
|
||||
"details": m4,
|
||||
"detectionTip": MessageLookupByLibrary.simpleMessage("サードパーティAPIに依存(参考値)"),
|
||||
"developerMode": MessageLookupByLibrary.simpleMessage("デベロッパーモード"),
|
||||
"developerModeEnableTip": MessageLookupByLibrary.simpleMessage(
|
||||
@@ -300,10 +301,10 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"editProxyGroup": MessageLookupByLibrary.simpleMessage("プロキシグループを編集"),
|
||||
"editRule": MessageLookupByLibrary.simpleMessage("ルールを編集"),
|
||||
"editSsid": MessageLookupByLibrary.simpleMessage("SSIDを編集"),
|
||||
"emptyTip": m4,
|
||||
"emptyTip": m5,
|
||||
"en": MessageLookupByLibrary.simpleMessage("英語"),
|
||||
"entries": MessageLookupByLibrary.simpleMessage(" エントリ"),
|
||||
"entriesCount": m5,
|
||||
"entriesCount": m6,
|
||||
"exclude": MessageLookupByLibrary.simpleMessage("最近のタスクから非表示"),
|
||||
"excludeDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"アプリがバックグラウンド時に最近のタスクから非表示",
|
||||
@@ -314,7 +315,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"除外した SSID の Wi-Fi に接続すると、アプリの動作状態が自動的に切り替わります。",
|
||||
),
|
||||
"excludeType": MessageLookupByLibrary.simpleMessage("除外タイプ"),
|
||||
"existsTip": m6,
|
||||
"existsTip": m7,
|
||||
"exit": MessageLookupByLibrary.simpleMessage("終了"),
|
||||
"expand": MessageLookupByLibrary.simpleMessage("標準"),
|
||||
"expectedStatus": MessageLookupByLibrary.simpleMessage("期待されるステータス"),
|
||||
@@ -357,8 +358,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
),
|
||||
"geoOptions": MessageLookupByLibrary.simpleMessage("Geoオプション"),
|
||||
"geoResources": MessageLookupByLibrary.simpleMessage("Geoリソース"),
|
||||
"geoSkipped": m7,
|
||||
"geoUpdated": m8,
|
||||
"geoSkipped": m8,
|
||||
"geoUpdated": m9,
|
||||
"geodataLoader": MessageLookupByLibrary.simpleMessage("Geo低メモリモード"),
|
||||
"geodataLoaderDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"有効化するとGeo低メモリローダーを使用",
|
||||
@@ -382,8 +383,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"キーボードでアプリを制御",
|
||||
),
|
||||
"hours": MessageLookupByLibrary.simpleMessage("時間"),
|
||||
"hoursAgo": m9,
|
||||
"hoursCount": m10,
|
||||
"hoursAgo": m10,
|
||||
"hoursCount": m11,
|
||||
"icon": MessageLookupByLibrary.simpleMessage("アイコン"),
|
||||
"iconRecords": MessageLookupByLibrary.simpleMessage("アイコン履歴"),
|
||||
"iconStyle": MessageLookupByLibrary.simpleMessage("アイコンスタイル"),
|
||||
@@ -415,10 +416,10 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"interval": MessageLookupByLibrary.simpleMessage("インターバル"),
|
||||
"intranetIP": MessageLookupByLibrary.simpleMessage("イントラネットIP"),
|
||||
"invalidBackupFile": MessageLookupByLibrary.simpleMessage("無効なバックアップファイル"),
|
||||
"invalidPolicy": m11,
|
||||
"invalidProxy": m12,
|
||||
"invalidProxyProvider": m13,
|
||||
"invalidSubRule": m14,
|
||||
"invalidPolicy": m12,
|
||||
"invalidProxy": m13,
|
||||
"invalidProxyProvider": m14,
|
||||
"invalidSubRule": m15,
|
||||
"ipcidr": MessageLookupByLibrary.simpleMessage("IPCIDR"),
|
||||
"ipv6Desc": MessageLookupByLibrary.simpleMessage("有効化するとIPv6トラフィックを受信可能"),
|
||||
"ipv6InboundDesc": MessageLookupByLibrary.simpleMessage("IPv6インバウンドを許可"),
|
||||
@@ -429,6 +430,10 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
),
|
||||
"key": MessageLookupByLibrary.simpleMessage("キー"),
|
||||
"language": MessageLookupByLibrary.simpleMessage("言語"),
|
||||
"launchInterrupted": MessageLookupByLibrary.simpleMessage("起動が完了しませんでした"),
|
||||
"launchInterruptedTip": MessageLookupByLibrary.simpleMessage(
|
||||
"前回、アプリは起動中に予期せず終了しました。今回は設定の自動セットアップをスキップしました。手動で起動して再試行できます。",
|
||||
),
|
||||
"layout": MessageLookupByLibrary.simpleMessage("レイアウト"),
|
||||
"light": MessageLookupByLibrary.simpleMessage("ライト"),
|
||||
"list": MessageLookupByLibrary.simpleMessage("リスト"),
|
||||
@@ -443,7 +448,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"locationPermissionDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"システムの要件により、Wi-Fi 名を取得するには位置情報の権限を許可する必要があります。",
|
||||
),
|
||||
"locationPermissionGuide": m15,
|
||||
"locationPermissionGuide": m16,
|
||||
"locationPermissionRequired": MessageLookupByLibrary.simpleMessage(
|
||||
"位置情報の権限が必要です",
|
||||
),
|
||||
@@ -469,11 +474,11 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"minimizeOnExitDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"システムの終了イベントを変更",
|
||||
),
|
||||
"minutesAgo": m16,
|
||||
"minutesAgo": m17,
|
||||
"mixedPort": MessageLookupByLibrary.simpleMessage("混合ポート"),
|
||||
"mode": MessageLookupByLibrary.simpleMessage("モード"),
|
||||
"monochromeScheme": MessageLookupByLibrary.simpleMessage("モノクローム"),
|
||||
"monthsAgo": m17,
|
||||
"monthsAgo": m18,
|
||||
"more": MessageLookupByLibrary.simpleMessage("詳細"),
|
||||
"name": MessageLookupByLibrary.simpleMessage("名前"),
|
||||
"nameserver": MessageLookupByLibrary.simpleMessage("ネームサーバー"),
|
||||
@@ -508,8 +513,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"nullProfileDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"プロファイルがありません。追加してください",
|
||||
),
|
||||
"nullTip": m18,
|
||||
"numberTip": m19,
|
||||
"nullTip": m19,
|
||||
"numberTip": m20,
|
||||
"onDemand": MessageLookupByLibrary.simpleMessage("オンデマンド"),
|
||||
"onDemandDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"特定のシーンでのアプリの動作状態を設定します",
|
||||
@@ -554,7 +559,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
),
|
||||
"port": MessageLookupByLibrary.simpleMessage("ポート"),
|
||||
"portConflictTip": MessageLookupByLibrary.simpleMessage("別のポートを入力してください"),
|
||||
"portTip": m20,
|
||||
"portTip": m21,
|
||||
"preferH3Desc": MessageLookupByLibrary.simpleMessage("DOHのHTTP/3を優先使用"),
|
||||
"prerequisites": MessageLookupByLibrary.simpleMessage("前提条件"),
|
||||
"pressKeyboard": MessageLookupByLibrary.simpleMessage("キーボードを押してください"),
|
||||
@@ -583,7 +588,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"project": MessageLookupByLibrary.simpleMessage("プロジェクト"),
|
||||
"providers": MessageLookupByLibrary.simpleMessage("プロバイダー"),
|
||||
"proxies": MessageLookupByLibrary.simpleMessage("プロキシ"),
|
||||
"proxiesCount": m21,
|
||||
"proxiesCount": m22,
|
||||
"proxiesEmpty": MessageLookupByLibrary.simpleMessage("プロキシが空です"),
|
||||
"proxyChains": MessageLookupByLibrary.simpleMessage("プロキシチェーン"),
|
||||
"proxyDetectedAbnormal": MessageLookupByLibrary.simpleMessage(
|
||||
@@ -765,7 +770,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"ruleSet": MessageLookupByLibrary.simpleMessage("ルールセット"),
|
||||
"ruleTarget": MessageLookupByLibrary.simpleMessage("ルール対象"),
|
||||
"rules": MessageLookupByLibrary.simpleMessage("ルール"),
|
||||
"rulesCount": m22,
|
||||
"rulesCount": m23,
|
||||
"save": MessageLookupByLibrary.simpleMessage("保存"),
|
||||
"saveChanges": MessageLookupByLibrary.simpleMessage("変更を保存しますか?"),
|
||||
"script": MessageLookupByLibrary.simpleMessage("スクリプト"),
|
||||
@@ -775,7 +780,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"scrollToSelected": MessageLookupByLibrary.simpleMessage("選択項目へスクロール"),
|
||||
"search": MessageLookupByLibrary.simpleMessage("検索"),
|
||||
"seconds": MessageLookupByLibrary.simpleMessage("秒"),
|
||||
"secondsCount": m23,
|
||||
"secondsCount": m24,
|
||||
"selectAll": MessageLookupByLibrary.simpleMessage("すべて選択"),
|
||||
"selectProxies": MessageLookupByLibrary.simpleMessage("プロキシを選択"),
|
||||
"selectProxyProviders": MessageLookupByLibrary.simpleMessage(
|
||||
@@ -787,7 +792,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
),
|
||||
"selectSubRule": MessageLookupByLibrary.simpleMessage("サブルールを選択してください"),
|
||||
"selected": MessageLookupByLibrary.simpleMessage("選択済み"),
|
||||
"selectedCountTitle": m24,
|
||||
"selectedCountTitle": m25,
|
||||
"settings": MessageLookupByLibrary.simpleMessage("設定"),
|
||||
"show": MessageLookupByLibrary.simpleMessage("表示"),
|
||||
"showLess": MessageLookupByLibrary.simpleMessage("折りたたむ"),
|
||||
@@ -878,7 +883,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"upload": MessageLookupByLibrary.simpleMessage("アップロード"),
|
||||
"url": MessageLookupByLibrary.simpleMessage("URL"),
|
||||
"urlDesc": MessageLookupByLibrary.simpleMessage("URL経由でプロファイルを取得"),
|
||||
"urlTip": m25,
|
||||
"urlTip": m26,
|
||||
"useHosts": MessageLookupByLibrary.simpleMessage("ホストを使用"),
|
||||
"useSystemHosts": MessageLookupByLibrary.simpleMessage("システムホストを使用"),
|
||||
"usedTraffic": MessageLookupByLibrary.simpleMessage("使用済み通信量"),
|
||||
@@ -895,7 +900,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"vpnTip": MessageLookupByLibrary.simpleMessage("変更はVPN再起動後に有効"),
|
||||
"webDAVConfiguration": MessageLookupByLibrary.simpleMessage("WebDAV設定"),
|
||||
"whitelistMode": MessageLookupByLibrary.simpleMessage("ホワイトリストモード"),
|
||||
"yearsAgo": m26,
|
||||
"yearsAgo": m27,
|
||||
"zh_CN": MessageLookupByLibrary.simpleMessage("簡体字中国語"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,67 +20,70 @@ typedef String MessageIfAbsent(String messageStr, List<dynamic> args);
|
||||
class MessageLookup extends MessageLookupByLibrary {
|
||||
String get localeName => 'ru';
|
||||
|
||||
static String m0(count) =>
|
||||
static String m0(name) =>
|
||||
"Приложение два раза подряд не смогло завершить запуск. Чтобы разорвать цикл, профиль ${name} снят с выбора, а автоматическая настройка пропущена. Вы можете выбрать его снова в любой момент.";
|
||||
|
||||
static String m1(count) =>
|
||||
"${Intl.plural(count, one: '${count} день назад', few: '${count} дня назад', many: '${count} дней назад', other: '${count} дня назад')}";
|
||||
|
||||
static String m1(label) =>
|
||||
static String m2(label) =>
|
||||
"Вы уверены, что хотите удалить выбранные ${label}?";
|
||||
|
||||
static String m2(label) => "Вы уверены, что хотите удалить текущий ${label}?";
|
||||
static String m3(label) => "Вы уверены, что хотите удалить текущий ${label}?";
|
||||
|
||||
static String m3(label) => "Детали {}";
|
||||
static String m4(label) => "Детали {}";
|
||||
|
||||
static String m4(label) => "${label} не может быть пустым";
|
||||
static String m5(label) => "${label} не может быть пустым";
|
||||
|
||||
static String m5(count) => "${count} записей";
|
||||
static String m6(count) => "${count} записей";
|
||||
|
||||
static String m6(label) => "Текущий ${label} уже существует";
|
||||
static String m7(label) => "Текущий ${label} уже существует";
|
||||
|
||||
static String m7(name) => "Для ${name} уже установлена последняя версия";
|
||||
static String m8(name) => "Для ${name} уже установлена последняя версия";
|
||||
|
||||
static String m8(name) => "${name} обновлено";
|
||||
static String m9(name) => "${name} обновлено";
|
||||
|
||||
static String m9(count) =>
|
||||
static String m10(count) =>
|
||||
"${Intl.plural(count, one: '${count} час назад', few: '${count} часа назад', many: '${count} часов назад', other: '${count} часа назад')}";
|
||||
|
||||
static String m10(count) => "${count} часов";
|
||||
static String m11(count) => "${count} часов";
|
||||
|
||||
static String m11(target) => "${target} является недопустимой политикой";
|
||||
static String m12(target) => "${target} является недопустимой политикой";
|
||||
|
||||
static String m12(proxyName) => "${proxyName} является недопустимым прокси";
|
||||
static String m13(proxyName) => "${proxyName} является недопустимым прокси";
|
||||
|
||||
static String m13(providerName) =>
|
||||
static String m14(providerName) =>
|
||||
"${providerName} является недопустимым провайдером прокси";
|
||||
|
||||
static String m14(subRule) => "${subRule} является недопустимым подправилом";
|
||||
static String m15(subRule) => "${subRule} является недопустимым подправилом";
|
||||
|
||||
static String m15(appName) =>
|
||||
static String m16(appName) =>
|
||||
"1. Откройте Системные настройки > Конфиденциальность и безопасность\n2. Выберите Службы геолокации\n3. Найдите и отметьте ${appName} в списке справа\n\nПосле настройки вернитесь в приложение и продолжайте работу. Спасибо за сотрудничество.";
|
||||
|
||||
static String m16(count) =>
|
||||
static String m17(count) =>
|
||||
"${Intl.plural(count, one: '${count} минута назад', few: '${count} минуты назад', many: '${count} минут назад', other: '${count} минуты назад')}";
|
||||
|
||||
static String m17(count) =>
|
||||
static String m18(count) =>
|
||||
"${Intl.plural(count, one: '${count} месяц назад', few: '${count} месяца назад', many: '${count} месяцев назад', other: '${count} месяца назад')}";
|
||||
|
||||
static String m18(label) => "${label} пока отсутствуют";
|
||||
static String m19(label) => "${label} пока отсутствуют";
|
||||
|
||||
static String m19(label) => "${label} должно быть числом";
|
||||
static String m20(label) => "${label} должно быть числом";
|
||||
|
||||
static String m20(label) => "${label} должен быть числом от 1024 до 49151";
|
||||
static String m21(label) => "${label} должен быть числом от 1024 до 49151";
|
||||
|
||||
static String m21(count) => "${count} прокси";
|
||||
static String m22(count) => "${count} прокси";
|
||||
|
||||
static String m22(count) =>
|
||||
static String m23(count) =>
|
||||
"${Intl.plural(count, one: '${count} правило', few: '${count} правила', many: '${count} правил', other: '${count} правила')}";
|
||||
|
||||
static String m23(count) => "${count} секунд";
|
||||
static String m24(count) => "${count} секунд";
|
||||
|
||||
static String m24(count) => "Выбрано ${count} элементов";
|
||||
static String m25(count) => "Выбрано ${count} элементов";
|
||||
|
||||
static String m25(label) => "${label} должен быть URL";
|
||||
static String m26(label) => "${label} должен быть URL";
|
||||
|
||||
static String m26(count) =>
|
||||
static String m27(count) =>
|
||||
"${Intl.plural(count, one: '${count} год назад', few: '${count} года назад', many: '${count} лет назад', other: '${count} года назад')}";
|
||||
|
||||
final messages = _notInlinedMessages(_notInlinedMessages);
|
||||
@@ -308,9 +311,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"coreStatus": MessageLookupByLibrary.simpleMessage("Основной статус"),
|
||||
"country": MessageLookupByLibrary.simpleMessage("Страна"),
|
||||
"crashDetected": MessageLookupByLibrary.simpleMessage("Обнаружен сбой"),
|
||||
"crashDetectedTip": MessageLookupByLibrary.simpleMessage(
|
||||
"Во время предыдущего запуска произошёл сбой приложения. Чтобы предотвратить повторный сбой, текущий профиль был сброшен, а автоматическая настройка конфигурации пропущена.",
|
||||
),
|
||||
"crashDetectedTip": m0,
|
||||
"crashTest": MessageLookupByLibrary.simpleMessage("Тест на сбои"),
|
||||
"crashlytics": MessageLookupByLibrary.simpleMessage("Анализ сбоев"),
|
||||
"crashlyticsTip": MessageLookupByLibrary.simpleMessage(
|
||||
@@ -335,7 +336,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"databaseWriteFailedTip": MessageLookupByLibrary.simpleMessage(
|
||||
"Не удалось сохранить изменение, оно было отменено",
|
||||
),
|
||||
"daysAgo": m0,
|
||||
"daysAgo": m1,
|
||||
"defaultNameserver": MessageLookupByLibrary.simpleMessage(
|
||||
"Сервер имен по умолчанию",
|
||||
),
|
||||
@@ -346,8 +347,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"delay": MessageLookupByLibrary.simpleMessage("Задержка"),
|
||||
"delayTest": MessageLookupByLibrary.simpleMessage("Тест задержки"),
|
||||
"delete": MessageLookupByLibrary.simpleMessage("Удалить"),
|
||||
"deleteMultipTip": m1,
|
||||
"deleteTip": m2,
|
||||
"deleteMultipTip": m2,
|
||||
"deleteTip": m3,
|
||||
"desc": MessageLookupByLibrary.simpleMessage(
|
||||
"Многоплатформенный прокси-клиент на основе ClashMeta, простой и удобный в использовании, с открытым исходным кодом и без рекламы.",
|
||||
),
|
||||
@@ -356,7 +357,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Геолокация назначения",
|
||||
),
|
||||
"destinationIPASN": MessageLookupByLibrary.simpleMessage("ASN назначения"),
|
||||
"details": m3,
|
||||
"details": m4,
|
||||
"detectionTip": MessageLookupByLibrary.simpleMessage(
|
||||
"Опирается на сторонний API, только для справки",
|
||||
),
|
||||
@@ -396,10 +397,10 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
),
|
||||
"editRule": MessageLookupByLibrary.simpleMessage("Редактировать правило"),
|
||||
"editSsid": MessageLookupByLibrary.simpleMessage("Изменить SSID"),
|
||||
"emptyTip": m4,
|
||||
"emptyTip": m5,
|
||||
"en": MessageLookupByLibrary.simpleMessage("Английский"),
|
||||
"entries": MessageLookupByLibrary.simpleMessage(" записей"),
|
||||
"entriesCount": m5,
|
||||
"entriesCount": m6,
|
||||
"exclude": MessageLookupByLibrary.simpleMessage(
|
||||
"Скрыть из последних задач",
|
||||
),
|
||||
@@ -414,7 +415,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"При подключении к Wi-Fi с исключённым SSID состояние работы приложения переключается автоматически.",
|
||||
),
|
||||
"excludeType": MessageLookupByLibrary.simpleMessage("Тип исключения"),
|
||||
"existsTip": m6,
|
||||
"existsTip": m7,
|
||||
"exit": MessageLookupByLibrary.simpleMessage("Выход"),
|
||||
"expand": MessageLookupByLibrary.simpleMessage("Стандартный"),
|
||||
"expectedStatus": MessageLookupByLibrary.simpleMessage("Ожидаемый статус"),
|
||||
@@ -467,8 +468,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
),
|
||||
"geoOptions": MessageLookupByLibrary.simpleMessage("Настройки Geo"),
|
||||
"geoResources": MessageLookupByLibrary.simpleMessage("Ресурсы Geo"),
|
||||
"geoSkipped": m7,
|
||||
"geoUpdated": m8,
|
||||
"geoSkipped": m8,
|
||||
"geoUpdated": m9,
|
||||
"geodataLoader": MessageLookupByLibrary.simpleMessage(
|
||||
"Режим низкого потребления памяти для геоданных",
|
||||
),
|
||||
@@ -502,8 +503,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Использование клавиатуры для управления приложением",
|
||||
),
|
||||
"hours": MessageLookupByLibrary.simpleMessage("часов"),
|
||||
"hoursAgo": m9,
|
||||
"hoursCount": m10,
|
||||
"hoursAgo": m10,
|
||||
"hoursCount": m11,
|
||||
"icon": MessageLookupByLibrary.simpleMessage("Иконка"),
|
||||
"iconRecords": MessageLookupByLibrary.simpleMessage("История иконок"),
|
||||
"iconStyle": MessageLookupByLibrary.simpleMessage("Стиль иконки"),
|
||||
@@ -549,10 +550,10 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"invalidBackupFile": MessageLookupByLibrary.simpleMessage(
|
||||
"Неверный файл резервной копии",
|
||||
),
|
||||
"invalidPolicy": m11,
|
||||
"invalidProxy": m12,
|
||||
"invalidProxyProvider": m13,
|
||||
"invalidSubRule": m14,
|
||||
"invalidPolicy": m12,
|
||||
"invalidProxy": m13,
|
||||
"invalidProxyProvider": m14,
|
||||
"invalidSubRule": m15,
|
||||
"ipcidr": MessageLookupByLibrary.simpleMessage("IPCIDR"),
|
||||
"ipv6Desc": MessageLookupByLibrary.simpleMessage(
|
||||
"При включении будет возможно получать IPv6 трафик",
|
||||
@@ -567,6 +568,12 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
),
|
||||
"key": MessageLookupByLibrary.simpleMessage("Ключ"),
|
||||
"language": MessageLookupByLibrary.simpleMessage("Язык"),
|
||||
"launchInterrupted": MessageLookupByLibrary.simpleMessage(
|
||||
"Запуск не завершён",
|
||||
),
|
||||
"launchInterruptedTip": MessageLookupByLibrary.simpleMessage(
|
||||
"В прошлый раз приложение неожиданно завершилось во время запуска. Автоматическая настройка для этого запуска пропущена; вы можете запустить её вручную.",
|
||||
),
|
||||
"layout": MessageLookupByLibrary.simpleMessage("Макет"),
|
||||
"light": MessageLookupByLibrary.simpleMessage("Светлый"),
|
||||
"list": MessageLookupByLibrary.simpleMessage("Список"),
|
||||
@@ -585,7 +592,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"locationPermissionDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"По требованию системы для получения имени сети Wi-Fi необходимо разрешение на геолокацию.",
|
||||
),
|
||||
"locationPermissionGuide": m15,
|
||||
"locationPermissionGuide": m16,
|
||||
"locationPermissionRequired": MessageLookupByLibrary.simpleMessage(
|
||||
"Требуется разрешение на геолокацию",
|
||||
),
|
||||
@@ -625,11 +632,11 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"minimizeOnExitDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"Изменить стандартное событие выхода из системы",
|
||||
),
|
||||
"minutesAgo": m16,
|
||||
"minutesAgo": m17,
|
||||
"mixedPort": MessageLookupByLibrary.simpleMessage("Смешанный порт"),
|
||||
"mode": MessageLookupByLibrary.simpleMessage("Режим"),
|
||||
"monochromeScheme": MessageLookupByLibrary.simpleMessage("Монохром"),
|
||||
"monthsAgo": m17,
|
||||
"monthsAgo": m18,
|
||||
"more": MessageLookupByLibrary.simpleMessage("Еще"),
|
||||
"name": MessageLookupByLibrary.simpleMessage("Имя"),
|
||||
"nameserver": MessageLookupByLibrary.simpleMessage("Сервер имен"),
|
||||
@@ -676,8 +683,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"nullProfileDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"Нет профиля, пожалуйста, добавьте профиль",
|
||||
),
|
||||
"nullTip": m18,
|
||||
"numberTip": m19,
|
||||
"nullTip": m19,
|
||||
"numberTip": m20,
|
||||
"onDemand": MessageLookupByLibrary.simpleMessage("По требованию"),
|
||||
"onDemandDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"Настройте состояние работы приложения для определённых сценариев",
|
||||
@@ -736,7 +743,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"portConflictTip": MessageLookupByLibrary.simpleMessage(
|
||||
"Введите другой порт",
|
||||
),
|
||||
"portTip": m20,
|
||||
"portTip": m21,
|
||||
"preferH3Desc": MessageLookupByLibrary.simpleMessage(
|
||||
"Приоритетное использование HTTP/3 для DOH",
|
||||
),
|
||||
@@ -777,7 +784,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"project": MessageLookupByLibrary.simpleMessage("Проект"),
|
||||
"providers": MessageLookupByLibrary.simpleMessage("Провайдеры"),
|
||||
"proxies": MessageLookupByLibrary.simpleMessage("Прокси"),
|
||||
"proxiesCount": m21,
|
||||
"proxiesCount": m22,
|
||||
"proxiesEmpty": MessageLookupByLibrary.simpleMessage("Список прокси пуст"),
|
||||
"proxyChains": MessageLookupByLibrary.simpleMessage("Цепочки прокси"),
|
||||
"proxyDetectedAbnormal": MessageLookupByLibrary.simpleMessage(
|
||||
@@ -1001,7 +1008,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"ruleSet": MessageLookupByLibrary.simpleMessage("Набор правил"),
|
||||
"ruleTarget": MessageLookupByLibrary.simpleMessage("Цель правила"),
|
||||
"rules": MessageLookupByLibrary.simpleMessage("Правила"),
|
||||
"rulesCount": m22,
|
||||
"rulesCount": m23,
|
||||
"save": MessageLookupByLibrary.simpleMessage("Сохранить"),
|
||||
"saveChanges": MessageLookupByLibrary.simpleMessage("Сохранить изменения?"),
|
||||
"script": MessageLookupByLibrary.simpleMessage("Скрипт"),
|
||||
@@ -1013,7 +1020,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
),
|
||||
"search": MessageLookupByLibrary.simpleMessage("Поиск"),
|
||||
"seconds": MessageLookupByLibrary.simpleMessage("Секунд"),
|
||||
"secondsCount": m23,
|
||||
"secondsCount": m24,
|
||||
"selectAll": MessageLookupByLibrary.simpleMessage("Выбрать все"),
|
||||
"selectProxies": MessageLookupByLibrary.simpleMessage("Выбрать прокси"),
|
||||
"selectProxyProviders": MessageLookupByLibrary.simpleMessage(
|
||||
@@ -1029,7 +1036,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Пожалуйста, выберите подправило",
|
||||
),
|
||||
"selected": MessageLookupByLibrary.simpleMessage("Выбрано"),
|
||||
"selectedCountTitle": m24,
|
||||
"selectedCountTitle": m25,
|
||||
"settings": MessageLookupByLibrary.simpleMessage("Настройки"),
|
||||
"show": MessageLookupByLibrary.simpleMessage("Показать"),
|
||||
"showLess": MessageLookupByLibrary.simpleMessage("Показать меньше"),
|
||||
@@ -1154,7 +1161,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"urlDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"Получить профиль через URL",
|
||||
),
|
||||
"urlTip": m25,
|
||||
"urlTip": m26,
|
||||
"useHosts": MessageLookupByLibrary.simpleMessage("Использовать hosts"),
|
||||
"useSystemHosts": MessageLookupByLibrary.simpleMessage(
|
||||
"Использовать системные hosts",
|
||||
@@ -1181,7 +1188,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"whitelistMode": MessageLookupByLibrary.simpleMessage(
|
||||
"Режим белого списка",
|
||||
),
|
||||
"yearsAgo": m26,
|
||||
"yearsAgo": m27,
|
||||
"zh_CN": MessageLookupByLibrary.simpleMessage("Упрощенный китайский"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,60 +20,63 @@ typedef String MessageIfAbsent(String messageStr, List<dynamic> args);
|
||||
class MessageLookup extends MessageLookupByLibrary {
|
||||
String get localeName => 'zh_CN';
|
||||
|
||||
static String m0(count) => "${count} 天前";
|
||||
static String m0(name) =>
|
||||
"应用连续两次未能完成启动。为打断崩溃循环,已取消选中配置 ${name},并跳过本次自动配置,你可以随时重新选中它。";
|
||||
|
||||
static String m1(label) => "确定删除选中的${label}吗?";
|
||||
static String m1(count) => "${count} 天前";
|
||||
|
||||
static String m2(label) => "确定删除当前${label}吗?";
|
||||
static String m2(label) => "确定删除选中的${label}吗?";
|
||||
|
||||
static String m3(label) => "${label}详情";
|
||||
static String m3(label) => "确定删除当前${label}吗?";
|
||||
|
||||
static String m4(label) => "${label}不能为空";
|
||||
static String m4(label) => "${label}详情";
|
||||
|
||||
static String m5(count) => "${count} 个条目";
|
||||
static String m5(label) => "${label}不能为空";
|
||||
|
||||
static String m6(label) => "${label}当前已存在";
|
||||
static String m6(count) => "${count} 个条目";
|
||||
|
||||
static String m7(name) => "${name} 已是最新版本";
|
||||
static String m7(label) => "${label}当前已存在";
|
||||
|
||||
static String m8(name) => "${name} 已更新";
|
||||
static String m8(name) => "${name} 已是最新版本";
|
||||
|
||||
static String m9(count) => "${count} 小时前";
|
||||
static String m9(name) => "${name} 已更新";
|
||||
|
||||
static String m10(count) => "${count} 小时";
|
||||
static String m10(count) => "${count} 小时前";
|
||||
|
||||
static String m11(target) => "${target} 是一个无效的策略";
|
||||
static String m11(count) => "${count} 小时";
|
||||
|
||||
static String m12(proxyName) => "${proxyName} 是一个无效的代理";
|
||||
static String m12(target) => "${target} 是一个无效的策略";
|
||||
|
||||
static String m13(providerName) => "${providerName} 是一个无效的代理集";
|
||||
static String m13(proxyName) => "${proxyName} 是一个无效的代理";
|
||||
|
||||
static String m14(subRule) => "${subRule} 是一个无效的SUB_RULE";
|
||||
static String m14(providerName) => "${providerName} 是一个无效的代理集";
|
||||
|
||||
static String m15(appName) =>
|
||||
static String m15(subRule) => "${subRule} 是一个无效的SUB_RULE";
|
||||
|
||||
static String m16(appName) =>
|
||||
"1. 打开 系统设置 > 隐私与安全性\n2. 选择 定位服务\n3. 在右侧列表中找到并勾选 ${appName}\n\n完成设置后,返回应用即可正常使用。感谢您的配合。";
|
||||
|
||||
static String m16(count) => "${count} 分钟前";
|
||||
static String m17(count) => "${count} 分钟前";
|
||||
|
||||
static String m17(count) => "${count} 个月前";
|
||||
static String m18(count) => "${count} 个月前";
|
||||
|
||||
static String m18(label) => "暂无${label}";
|
||||
static String m19(label) => "暂无${label}";
|
||||
|
||||
static String m19(label) => "${label}必须为数字";
|
||||
static String m20(label) => "${label}必须为数字";
|
||||
|
||||
static String m20(label) => "${label} 必须在 1024 到 49151 之间";
|
||||
static String m21(label) => "${label} 必须在 1024 到 49151 之间";
|
||||
|
||||
static String m21(count) => "${count} 个代理";
|
||||
static String m22(count) => "${count} 个代理";
|
||||
|
||||
static String m22(count) => "${count} 条规则";
|
||||
static String m23(count) => "${count} 条规则";
|
||||
|
||||
static String m23(count) => "${count} 秒";
|
||||
static String m24(count) => "${count} 秒";
|
||||
|
||||
static String m24(count) => "已选择 ${count} 项";
|
||||
static String m25(count) => "已选择 ${count} 项";
|
||||
|
||||
static String m25(label) => "${label}必须为URL";
|
||||
static String m26(label) => "${label}必须为URL";
|
||||
|
||||
static String m26(count) => "${count} 年前";
|
||||
static String m27(count) => "${count} 年前";
|
||||
|
||||
final messages = _notInlinedMessages(_notInlinedMessages);
|
||||
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
|
||||
@@ -210,9 +213,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"coreStatus": MessageLookupByLibrary.simpleMessage("核心状态"),
|
||||
"country": MessageLookupByLibrary.simpleMessage("区域"),
|
||||
"crashDetected": MessageLookupByLibrary.simpleMessage("检测到崩溃"),
|
||||
"crashDetectedTip": MessageLookupByLibrary.simpleMessage(
|
||||
"检测到应用上次运行发生崩溃。为避免重复崩溃,已清除当前配置选择,并跳过本次自动配置。",
|
||||
),
|
||||
"crashDetectedTip": m0,
|
||||
"crashTest": MessageLookupByLibrary.simpleMessage("崩溃测试"),
|
||||
"crashlytics": MessageLookupByLibrary.simpleMessage("崩溃分析"),
|
||||
"crashlyticsTip": MessageLookupByLibrary.simpleMessage(
|
||||
@@ -233,22 +234,22 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"databaseWriteFailedTip": MessageLookupByLibrary.simpleMessage(
|
||||
"保存更改失败,已回滚",
|
||||
),
|
||||
"daysAgo": m0,
|
||||
"daysAgo": m1,
|
||||
"defaultNameserver": MessageLookupByLibrary.simpleMessage("默认域名服务器"),
|
||||
"defaultNameserverDesc": MessageLookupByLibrary.simpleMessage("用于解析DNS服务器"),
|
||||
"defaultText": MessageLookupByLibrary.simpleMessage("默认"),
|
||||
"delay": MessageLookupByLibrary.simpleMessage("延迟"),
|
||||
"delayTest": MessageLookupByLibrary.simpleMessage("延迟测试"),
|
||||
"delete": MessageLookupByLibrary.simpleMessage("删除"),
|
||||
"deleteMultipTip": m1,
|
||||
"deleteTip": m2,
|
||||
"deleteMultipTip": m2,
|
||||
"deleteTip": m3,
|
||||
"desc": MessageLookupByLibrary.simpleMessage(
|
||||
"基于ClashMeta的多平台代理客户端,简单易用,开源无广告。",
|
||||
),
|
||||
"destination": MessageLookupByLibrary.simpleMessage("目标地址"),
|
||||
"destinationGeoIP": MessageLookupByLibrary.simpleMessage("目标地理定位"),
|
||||
"destinationIPASN": MessageLookupByLibrary.simpleMessage("目标IP ASN"),
|
||||
"details": m3,
|
||||
"details": m4,
|
||||
"detectionTip": MessageLookupByLibrary.simpleMessage("依赖第三方api,仅供参考"),
|
||||
"developerMode": MessageLookupByLibrary.simpleMessage("开发者模式"),
|
||||
"developerModeEnableTip": MessageLookupByLibrary.simpleMessage("开发者模式已启用。"),
|
||||
@@ -272,10 +273,10 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"editProxyGroup": MessageLookupByLibrary.simpleMessage("编辑策略组"),
|
||||
"editRule": MessageLookupByLibrary.simpleMessage("编辑规则"),
|
||||
"editSsid": MessageLookupByLibrary.simpleMessage("编辑SSID"),
|
||||
"emptyTip": m4,
|
||||
"emptyTip": m5,
|
||||
"en": MessageLookupByLibrary.simpleMessage("英语"),
|
||||
"entries": MessageLookupByLibrary.simpleMessage("个条目"),
|
||||
"entriesCount": m5,
|
||||
"entriesCount": m6,
|
||||
"exclude": MessageLookupByLibrary.simpleMessage("从最近任务中隐藏"),
|
||||
"excludeDesc": MessageLookupByLibrary.simpleMessage("应用在后台时,从最近任务中隐藏应用"),
|
||||
"excludeProxyFilter": MessageLookupByLibrary.simpleMessage("排除节点过滤器"),
|
||||
@@ -284,7 +285,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"连接到被排除SSID的WIFI时,将会自动切换应用运行状态",
|
||||
),
|
||||
"excludeType": MessageLookupByLibrary.simpleMessage("排除类型"),
|
||||
"existsTip": m6,
|
||||
"existsTip": m7,
|
||||
"exit": MessageLookupByLibrary.simpleMessage("退出"),
|
||||
"expand": MessageLookupByLibrary.simpleMessage("标准"),
|
||||
"expectedStatus": MessageLookupByLibrary.simpleMessage("预期状态"),
|
||||
@@ -321,8 +322,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
),
|
||||
"geoOptions": MessageLookupByLibrary.simpleMessage("Geo 选项"),
|
||||
"geoResources": MessageLookupByLibrary.simpleMessage("Geo 资源"),
|
||||
"geoSkipped": m7,
|
||||
"geoUpdated": m8,
|
||||
"geoSkipped": m8,
|
||||
"geoUpdated": m9,
|
||||
"geodataLoader": MessageLookupByLibrary.simpleMessage("Geo低内存模式"),
|
||||
"geodataLoaderDesc": MessageLookupByLibrary.simpleMessage("开启将使用Geo低内存加载器"),
|
||||
"geoipCode": MessageLookupByLibrary.simpleMessage("Geoip代码"),
|
||||
@@ -342,8 +343,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"hotkeyManagement": MessageLookupByLibrary.simpleMessage("快捷键管理"),
|
||||
"hotkeyManagementDesc": MessageLookupByLibrary.simpleMessage("使用键盘控制应用程序"),
|
||||
"hours": MessageLookupByLibrary.simpleMessage("小时"),
|
||||
"hoursAgo": m9,
|
||||
"hoursCount": m10,
|
||||
"hoursAgo": m10,
|
||||
"hoursCount": m11,
|
||||
"icon": MessageLookupByLibrary.simpleMessage("图片"),
|
||||
"iconRecords": MessageLookupByLibrary.simpleMessage("图标记录"),
|
||||
"iconStyle": MessageLookupByLibrary.simpleMessage("图标样式"),
|
||||
@@ -371,10 +372,10 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"interval": MessageLookupByLibrary.simpleMessage("间隔"),
|
||||
"intranetIP": MessageLookupByLibrary.simpleMessage("内网 IP"),
|
||||
"invalidBackupFile": MessageLookupByLibrary.simpleMessage("无效备份文件"),
|
||||
"invalidPolicy": m11,
|
||||
"invalidProxy": m12,
|
||||
"invalidProxyProvider": m13,
|
||||
"invalidSubRule": m14,
|
||||
"invalidPolicy": m12,
|
||||
"invalidProxy": m13,
|
||||
"invalidProxyProvider": m14,
|
||||
"invalidSubRule": m15,
|
||||
"ipcidr": MessageLookupByLibrary.simpleMessage("IP/掩码"),
|
||||
"ipv6Desc": MessageLookupByLibrary.simpleMessage("开启后将可以接收IPv6流量"),
|
||||
"ipv6InboundDesc": MessageLookupByLibrary.simpleMessage("允许IPv6入站"),
|
||||
@@ -383,6 +384,10 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"keepAliveIntervalDesc": MessageLookupByLibrary.simpleMessage("TCP保持活动间隔"),
|
||||
"key": MessageLookupByLibrary.simpleMessage("键"),
|
||||
"language": MessageLookupByLibrary.simpleMessage("语言"),
|
||||
"launchInterrupted": MessageLookupByLibrary.simpleMessage("启动未完成"),
|
||||
"launchInterruptedTip": MessageLookupByLibrary.simpleMessage(
|
||||
"应用上次在启动过程中意外退出。已跳过本次自动配置,你可以手动启动重试。",
|
||||
),
|
||||
"layout": MessageLookupByLibrary.simpleMessage("布局"),
|
||||
"light": MessageLookupByLibrary.simpleMessage("浅色"),
|
||||
"list": MessageLookupByLibrary.simpleMessage("列表"),
|
||||
@@ -397,7 +402,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"locationPermissionDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"根据系统要求,获取Wi-Fi名称需要您授予位置权限。",
|
||||
),
|
||||
"locationPermissionGuide": m15,
|
||||
"locationPermissionGuide": m16,
|
||||
"locationPermissionRequired": MessageLookupByLibrary.simpleMessage(
|
||||
"需要位置权限",
|
||||
),
|
||||
@@ -421,11 +426,11 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"minimize": MessageLookupByLibrary.simpleMessage("最小化"),
|
||||
"minimizeOnExit": MessageLookupByLibrary.simpleMessage("退出时最小化"),
|
||||
"minimizeOnExitDesc": MessageLookupByLibrary.simpleMessage("修改系统默认退出事件"),
|
||||
"minutesAgo": m16,
|
||||
"minutesAgo": m17,
|
||||
"mixedPort": MessageLookupByLibrary.simpleMessage("混合端口"),
|
||||
"mode": MessageLookupByLibrary.simpleMessage("模式"),
|
||||
"monochromeScheme": MessageLookupByLibrary.simpleMessage("单色"),
|
||||
"monthsAgo": m17,
|
||||
"monthsAgo": m18,
|
||||
"more": MessageLookupByLibrary.simpleMessage("更多"),
|
||||
"name": MessageLookupByLibrary.simpleMessage("名称"),
|
||||
"nameserver": MessageLookupByLibrary.simpleMessage("域名服务器"),
|
||||
@@ -452,8 +457,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"none": MessageLookupByLibrary.simpleMessage("无"),
|
||||
"notSelectedTip": MessageLookupByLibrary.simpleMessage("当前代理组无法选中"),
|
||||
"nullProfileDesc": MessageLookupByLibrary.simpleMessage("没有配置文件,请先添加配置文件"),
|
||||
"nullTip": m18,
|
||||
"numberTip": m19,
|
||||
"nullTip": m19,
|
||||
"numberTip": m20,
|
||||
"onDemand": MessageLookupByLibrary.simpleMessage("按需运行"),
|
||||
"onDemandDesc": MessageLookupByLibrary.simpleMessage("配置程序特定场景运行状态"),
|
||||
"onlyIcon": MessageLookupByLibrary.simpleMessage("仅图标"),
|
||||
@@ -490,7 +495,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
),
|
||||
"port": MessageLookupByLibrary.simpleMessage("端口"),
|
||||
"portConflictTip": MessageLookupByLibrary.simpleMessage("请输入不同的端口"),
|
||||
"portTip": m20,
|
||||
"portTip": m21,
|
||||
"preferH3Desc": MessageLookupByLibrary.simpleMessage("优先使用DOH的http/3"),
|
||||
"prerequisites": MessageLookupByLibrary.simpleMessage("前置条件"),
|
||||
"pressKeyboard": MessageLookupByLibrary.simpleMessage("请按下按键"),
|
||||
@@ -519,7 +524,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"project": MessageLookupByLibrary.simpleMessage("项目"),
|
||||
"providers": MessageLookupByLibrary.simpleMessage("外部资源"),
|
||||
"proxies": MessageLookupByLibrary.simpleMessage("代理"),
|
||||
"proxiesCount": m21,
|
||||
"proxiesCount": m22,
|
||||
"proxiesEmpty": MessageLookupByLibrary.simpleMessage("代理为空"),
|
||||
"proxyChains": MessageLookupByLibrary.simpleMessage("代理链"),
|
||||
"proxyDetectedAbnormal": MessageLookupByLibrary.simpleMessage(
|
||||
@@ -665,7 +670,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"ruleSet": MessageLookupByLibrary.simpleMessage("规则集"),
|
||||
"ruleTarget": MessageLookupByLibrary.simpleMessage("规则目标"),
|
||||
"rules": MessageLookupByLibrary.simpleMessage("规则"),
|
||||
"rulesCount": m22,
|
||||
"rulesCount": m23,
|
||||
"save": MessageLookupByLibrary.simpleMessage("保存"),
|
||||
"saveChanges": MessageLookupByLibrary.simpleMessage("是否保存更改?"),
|
||||
"script": MessageLookupByLibrary.simpleMessage("脚本"),
|
||||
@@ -675,7 +680,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"scrollToSelected": MessageLookupByLibrary.simpleMessage("滚动到已选"),
|
||||
"search": MessageLookupByLibrary.simpleMessage("搜索"),
|
||||
"seconds": MessageLookupByLibrary.simpleMessage("秒"),
|
||||
"secondsCount": m23,
|
||||
"secondsCount": m24,
|
||||
"selectAll": MessageLookupByLibrary.simpleMessage("全选"),
|
||||
"selectProxies": MessageLookupByLibrary.simpleMessage("选择代理"),
|
||||
"selectProxyProviders": MessageLookupByLibrary.simpleMessage("选择代理集"),
|
||||
@@ -683,7 +688,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"selectSplitStrategy": MessageLookupByLibrary.simpleMessage("请选择分流策略"),
|
||||
"selectSubRule": MessageLookupByLibrary.simpleMessage("请选择子规则"),
|
||||
"selected": MessageLookupByLibrary.simpleMessage("已选择"),
|
||||
"selectedCountTitle": m24,
|
||||
"selectedCountTitle": m25,
|
||||
"settings": MessageLookupByLibrary.simpleMessage("设置"),
|
||||
"show": MessageLookupByLibrary.simpleMessage("显示"),
|
||||
"showLess": MessageLookupByLibrary.simpleMessage("收起"),
|
||||
@@ -768,7 +773,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"upload": MessageLookupByLibrary.simpleMessage("上传"),
|
||||
"url": MessageLookupByLibrary.simpleMessage("URL"),
|
||||
"urlDesc": MessageLookupByLibrary.simpleMessage("通过URL获取配置文件"),
|
||||
"urlTip": m25,
|
||||
"urlTip": m26,
|
||||
"useHosts": MessageLookupByLibrary.simpleMessage("使用Hosts"),
|
||||
"useSystemHosts": MessageLookupByLibrary.simpleMessage("使用系统Hosts"),
|
||||
"usedTraffic": MessageLookupByLibrary.simpleMessage("已用流量"),
|
||||
@@ -785,7 +790,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"vpnTip": MessageLookupByLibrary.simpleMessage("重启VPN后改变生效"),
|
||||
"webDAVConfiguration": MessageLookupByLibrary.simpleMessage("WebDAV配置"),
|
||||
"whitelistMode": MessageLookupByLibrary.simpleMessage("白名单模式"),
|
||||
"yearsAgo": m26,
|
||||
"yearsAgo": m27,
|
||||
"zh_CN": MessageLookupByLibrary.simpleMessage("中文简体"),
|
||||
};
|
||||
}
|
||||
|
||||
+23
-3
@@ -2499,12 +2499,32 @@ class AppLocalizations {
|
||||
);
|
||||
}
|
||||
|
||||
/// `The app crashed during the previous run. To prevent repeated crashes, the current profile has been cleared and automatic configuration setup was skipped.`
|
||||
String get crashDetectedTip {
|
||||
/// `The app failed to finish launching twice in a row. To break the loop, the profile {name} has been deselected and automatic setup was skipped. You can select it again at any time.`
|
||||
String crashDetectedTip(Object name) {
|
||||
return Intl.message(
|
||||
'The app crashed during the previous run. To prevent repeated crashes, the current profile has been cleared and automatic configuration setup was skipped.',
|
||||
'The app failed to finish launching twice in a row. To break the loop, the profile $name has been deselected and automatic setup was skipped. You can select it again at any time.',
|
||||
name: 'crashDetectedTip',
|
||||
desc: '',
|
||||
args: [name],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Launch did not finish`
|
||||
String get launchInterrupted {
|
||||
return Intl.message(
|
||||
'Launch did not finish',
|
||||
name: 'launchInterrupted',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `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.`
|
||||
String get launchInterruptedTip {
|
||||
return Intl.message(
|
||||
'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.',
|
||||
name: 'launchInterruptedTip',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
+120
-51
@@ -6,7 +6,6 @@ import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:window_ext/window_ext.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class WindowManager extends ConsumerStatefulWidget {
|
||||
@@ -19,7 +18,7 @@ class WindowManager extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _WindowContainerState extends ConsumerState<WindowManager>
|
||||
with WindowListener, WindowExtListener {
|
||||
with WindowListener {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.child;
|
||||
@@ -38,7 +37,6 @@ class _WindowContainerState extends ConsumerState<WindowManager>
|
||||
});
|
||||
}
|
||||
});
|
||||
windowExtManager.addListener(this);
|
||||
windowManager.addListener(this);
|
||||
}
|
||||
|
||||
@@ -56,9 +54,9 @@ class _WindowContainerState extends ConsumerState<WindowManager>
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onShouldTerminate() async {
|
||||
Future<void> onWindowShouldTerminate() async {
|
||||
await ref.read(systemActionProvider.notifier).handleExit();
|
||||
super.onShouldTerminate();
|
||||
super.onWindowShouldTerminate();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -106,7 +104,6 @@ class _WindowContainerState extends ConsumerState<WindowManager>
|
||||
@override
|
||||
void dispose() {
|
||||
windowManager.removeListener(this);
|
||||
windowExtManager.removeListener(this);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -122,19 +119,19 @@ class WindowHeaderContainer extends StatelessWidget {
|
||||
builder: (_, ref, child) {
|
||||
final isMobileView = ref.watch(isMobileViewProvider);
|
||||
final version = ref.watch(versionProvider);
|
||||
if ((version <= 10 || !isMobileView) && system.isMacOS) {
|
||||
final showsHeader = showsWindowHeader(
|
||||
isDesktop: system.isDesktop,
|
||||
isMacOS: system.isMacOS,
|
||||
version: version,
|
||||
isMobileView: isMobileView,
|
||||
);
|
||||
if (!showsHeader) {
|
||||
return child!;
|
||||
}
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(height: kHeaderHeight),
|
||||
Expanded(flex: 1, child: child!),
|
||||
],
|
||||
),
|
||||
const WindowHeader(),
|
||||
],
|
||||
return WindowHeaderLayout(
|
||||
height: kHeaderHeight,
|
||||
header: const WindowHeader(),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
@@ -142,6 +139,36 @@ class WindowHeaderContainer extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class WindowHeaderLayout extends StatelessWidget {
|
||||
const WindowHeaderLayout({
|
||||
super.key,
|
||||
required this.height,
|
||||
required this.header,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final double height;
|
||||
final Widget header;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Overlay.wrap(
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(height: height),
|
||||
Expanded(flex: 1, child: child),
|
||||
],
|
||||
),
|
||||
Positioned(top: 0, left: 0, right: 0, child: header),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WindowHeader extends ConsumerStatefulWidget {
|
||||
const WindowHeader({super.key});
|
||||
|
||||
@@ -179,12 +206,12 @@ class _WindowHeaderState extends ConsumerState<WindowHeader> {
|
||||
if (isMaximized) {
|
||||
await windowManager.unmaximize();
|
||||
if (system.isWindows) {
|
||||
unawaited(windowExtManager.setWindowCornerPreference(round: true));
|
||||
unawaited(windowManager.setWindowCornerPreference(round: true));
|
||||
}
|
||||
} else {
|
||||
await windowManager.maximize();
|
||||
if (system.isWindows) {
|
||||
unawaited(windowExtManager.setWindowCornerPreference(round: false));
|
||||
unawaited(windowManager.setWindowCornerPreference(round: false));
|
||||
}
|
||||
}
|
||||
final res = await windowManager.isMaximized();
|
||||
@@ -201,45 +228,87 @@ class _WindowHeaderState extends ConsumerState<WindowHeader> {
|
||||
isPinNotifier.value = res;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return WindowHeaderBar(
|
||||
height: kHeaderHeight,
|
||||
onDragStart: windowManager.startDragging,
|
||||
onDoubleTap: _updateMaximized,
|
||||
title: system.isMacOS ? const Text(appName) : null,
|
||||
actions: system.isMacOS
|
||||
? null
|
||||
: WindowHeaderActions(
|
||||
isPinNotifier: isPinNotifier,
|
||||
isMaximizedNotifier: isMaximizedNotifier,
|
||||
onPin: _updatePin,
|
||||
onMinimize: windowManager.minimize,
|
||||
onMaximize: _updateMaximized,
|
||||
onClose: () {
|
||||
ref.read(systemActionProvider.notifier).handleClose();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WindowHeaderBar extends StatelessWidget {
|
||||
const WindowHeaderBar({
|
||||
super.key,
|
||||
required this.height,
|
||||
required this.onDragStart,
|
||||
required this.onDoubleTap,
|
||||
this.title,
|
||||
this.actions,
|
||||
});
|
||||
|
||||
final double height;
|
||||
final VoidCallback onDragStart;
|
||||
final VoidCallback onDoubleTap;
|
||||
final Widget? title;
|
||||
final Widget? actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
child: Stack(
|
||||
alignment: AlignmentDirectional.center,
|
||||
children: [
|
||||
Positioned(
|
||||
child: GestureDetector(
|
||||
onPanStart: (_) {
|
||||
windowManager.startDragging();
|
||||
},
|
||||
onDoubleTap: () {
|
||||
_updateMaximized();
|
||||
},
|
||||
child: Container(
|
||||
color: context.colorScheme.secondary.opacity15,
|
||||
alignment: Alignment.centerLeft,
|
||||
height: kHeaderHeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (system.isMacOS)
|
||||
const Text(appName)
|
||||
else ...[
|
||||
Positioned(
|
||||
right: 0,
|
||||
child: WindowHeaderActions(
|
||||
isPinNotifier: isPinNotifier,
|
||||
isMaximizedNotifier: isMaximizedNotifier,
|
||||
onPin: _updatePin,
|
||||
onMinimize: windowManager.minimize,
|
||||
onMaximize: _updateMaximized,
|
||||
onClose: () {
|
||||
ref.read(systemActionProvider.notifier).handleClose();
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
child: Stack(
|
||||
alignment: AlignmentDirectional.center,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onPanStart: (_) {
|
||||
onDragStart();
|
||||
},
|
||||
onDoubleTap: onDoubleTap,
|
||||
child: ColoredBox(
|
||||
color: context.colorScheme.secondary.opacity15,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (title != null)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(child: Center(child: title)),
|
||||
),
|
||||
if (actions != null)
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: IconButtonTheme(
|
||||
data: IconButtonThemeData(
|
||||
style: ButtonStyle(
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
minimumSize: WidgetStatePropertyAll(Size.square(height)),
|
||||
maximumSize: WidgetStatePropertyAll(Size.square(height)),
|
||||
padding: const WidgetStatePropertyAll(EdgeInsets.zero),
|
||||
),
|
||||
),
|
||||
child: actions!,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+22
-4
@@ -1,12 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/boot_record.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
const _platformProbeTimeout = Duration(seconds: 2);
|
||||
|
||||
class App {
|
||||
static App? _instance;
|
||||
late MethodChannel methodChannel;
|
||||
@@ -138,10 +141,10 @@ class App {
|
||||
|
||||
Future<bool> didCrashOnPreviousExecution() async {
|
||||
try {
|
||||
return await methodChannel.invokeMethod<bool>(
|
||||
'didCrashOnPreviousExecution',
|
||||
) ??
|
||||
false;
|
||||
final value = await methodChannel
|
||||
.invokeMethod<bool>('didCrashOnPreviousExecution')
|
||||
.timeout(_platformProbeTimeout);
|
||||
return value ?? false;
|
||||
} catch (error) {
|
||||
commonPrint.log(
|
||||
'Failed to read the previous-execution crash flag: '
|
||||
@@ -151,6 +154,21 @@ class App {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppExitInfo?> getLastExitInfo() async {
|
||||
try {
|
||||
final raw = await methodChannel
|
||||
.invokeMapMethod<String, Object?>('getLastExitInfo')
|
||||
.timeout(_platformProbeTimeout);
|
||||
return AppExitInfo.fromJson(raw);
|
||||
} catch (error) {
|
||||
commonPrint.log(
|
||||
'Failed to read the last process exit info: ${compactError(error)}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final app = system.isAndroid ? App() : null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/boot_guard.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/common/system_dns.dart';
|
||||
import 'package:fl_clash/core/core.dart';
|
||||
|
||||
@@ -46,6 +46,7 @@ class SystemAction extends _$SystemAction {
|
||||
}
|
||||
await Future.wait([
|
||||
if (needSave) preferences.saveConfig(ref.read(configProvider)),
|
||||
bootGuard.markClosed(),
|
||||
if (systemDnsCoordinator != null) systemDnsCoordinator!.shutdown(),
|
||||
if (proxy != null) proxy!.stopProxy(),
|
||||
]);
|
||||
|
||||
@@ -110,9 +110,11 @@ double overlayTopOffset(Ref ref) {
|
||||
final isMobileView = ref.watch(isMobileViewProvider);
|
||||
final version = ref.watch(versionProvider);
|
||||
ref.watch(viewSizeProvider);
|
||||
double top = kHeaderHeight;
|
||||
if ((version <= 10 || !isMobileView) && system.isMacOS || !system.isDesktop) {
|
||||
top = 0;
|
||||
}
|
||||
return kToolbarHeight + top;
|
||||
final showsHeader = showsWindowHeader(
|
||||
isDesktop: system.isDesktop,
|
||||
isMacOS: system.isMacOS,
|
||||
version: version,
|
||||
isMobileView: isMobileView,
|
||||
);
|
||||
return kToolbarHeight + (showsHeader ? kHeaderHeight : 0);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:wifi_ssid/wifi_ssid.dart';
|
||||
|
||||
class OnDemandView extends ConsumerStatefulWidget {
|
||||
const OnDemandView({super.key});
|
||||
const OnDemandView({super.key, this.isAndroid, this.isMacOS});
|
||||
|
||||
final bool? isAndroid;
|
||||
final bool? isMacOS;
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _OnDemandViewState();
|
||||
@@ -20,8 +23,12 @@ class OnDemandView extends ConsumerStatefulWidget {
|
||||
|
||||
class _OnDemandViewState extends ConsumerState<OnDemandView>
|
||||
with UniqueKeyStateMixin {
|
||||
bool get _isAndroid => widget.isAndroid ?? system.isAndroid;
|
||||
|
||||
bool get _isMacOS => widget.isMacOS ?? system.isMacOS;
|
||||
|
||||
void _handlePermanentlyDeniedLocationPermission() {
|
||||
if (system.isMacOS) {
|
||||
if (_isMacOS) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
dialogs.showMessage(
|
||||
title: appLocalizations.locationPermissionRequired,
|
||||
@@ -31,7 +38,7 @@ class _OnDemandViewState extends ConsumerState<OnDemandView>
|
||||
text: appLocalizations.locationPermissionGuide(appName),
|
||||
),
|
||||
);
|
||||
} else if (system.isAndroid) {
|
||||
} else if (_isAndroid) {
|
||||
app?.openAppSettings();
|
||||
}
|
||||
}
|
||||
@@ -268,8 +275,8 @@ class _OnDemandViewState extends ConsumerState<OnDemandView>
|
||||
return generateSectionV3(
|
||||
title: context.appLocalizations.prerequisites,
|
||||
items: [
|
||||
if (system.isAndroid) _buildBatteryOptimizationItem(),
|
||||
if (system.isAndroid || system.isMacOS) _buildLocationPermissionItem(),
|
||||
if (_isAndroid) _buildBatteryOptimizationItem(),
|
||||
if (_isAndroid || _isMacOS) _buildLocationPermissionItem(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,12 +10,6 @@ Color _sheetColorOf(BuildContext context) {
|
||||
: ColorScheme.of(context).surface;
|
||||
}
|
||||
|
||||
/// A route for the [PagedSheet]'s nested navigator.
|
||||
///
|
||||
/// Mixes in [ObservableRouteMixin] and wraps its content in a
|
||||
/// [ResizableNavigatorRouteContentBoundary] so that the enclosing
|
||||
/// [NavigatorResizable] can size the sheet to the current page and animate
|
||||
/// the height along with the transition.
|
||||
class PagedSheetRoute<T> extends PageRoute<T> with ObservableRouteMixin<T> {
|
||||
PagedSheetRoute({
|
||||
super.settings,
|
||||
@@ -89,11 +83,6 @@ class PagedSheetRoute<T> extends PageRoute<T> with ObservableRouteMixin<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A sheet surface hosting a nested navigator of [PagedSheetRoute]s.
|
||||
///
|
||||
/// The sheet shrinks to the height of the current page, so it must be given
|
||||
/// bounded but non-tight constraints, e.g. by an [Align] that leaves the
|
||||
/// height loose.
|
||||
class PagedSheet extends StatelessWidget {
|
||||
const PagedSheet({
|
||||
super.key,
|
||||
@@ -107,7 +96,6 @@ class PagedSheet extends StatelessWidget {
|
||||
final ShapeBorder? shape;
|
||||
final Clip clipBehavior;
|
||||
|
||||
/// The nested [Navigator] whose current [PagedSheetRoute] drives the height.
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
|
||||
@@ -21,7 +21,6 @@ import sqflite_darwin
|
||||
import tray
|
||||
import url_launcher_macos
|
||||
import wifi_ssid
|
||||
import window_ext
|
||||
import window_manager
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
@@ -41,6 +40,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
TrayPlugin.register(with: registry.registrar(forPlugin: "TrayPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
WifiSsidPlugin.register(with: registry.registrar(forPlugin: "WifiSsidPlugin"))
|
||||
WindowExtPlugin.register(with: registry.registrar(forPlugin: "WindowExtPlugin"))
|
||||
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Cocoa
|
||||
import FlutterMacOS
|
||||
import window_ext
|
||||
import window_manager
|
||||
|
||||
@main
|
||||
class AppDelegate: FlutterAppDelegate {
|
||||
@@ -10,7 +10,7 @@ class AppDelegate: FlutterAppDelegate {
|
||||
}
|
||||
|
||||
override func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
WindowExtPlugin.instance?.handleShouldTerminate()
|
||||
WindowManagerPlugin.instance?.handleShouldTerminate()
|
||||
return .terminateCancel
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ include: ../../lint_options.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- cargokit/**
|
||||
# Generated by flutter_rust_bridge; regenerated by the Cargokit build hook.
|
||||
- lib/src/rust/**
|
||||
- build/**
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
build/
|
||||
@@ -1,33 +0,0 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "603104015dd692ea3403755b55d07813d5cf8965"
|
||||
channel: "stable"
|
||||
|
||||
project_type: plugin
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 603104015dd692ea3403755b55d07813d5cf8965
|
||||
base_revision: 603104015dd692ea3403755b55d07813d5cf8965
|
||||
- platform: macos
|
||||
create_revision: 603104015dd692ea3403755b55d07813d5cf8965
|
||||
base_revision: 603104015dd692ea3403755b55d07813d5cf8965
|
||||
- platform: windows
|
||||
create_revision: 603104015dd692ea3403755b55d07813d5cf8965
|
||||
base_revision: 603104015dd692ea3403755b55d07813d5cf8965
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -1,3 +0,0 @@
|
||||
## 0.0.1
|
||||
|
||||
* TODO: Describe initial release.
|
||||
@@ -1 +0,0 @@
|
||||
TODO: Add your license here.
|
||||
@@ -1,15 +0,0 @@
|
||||
# window_ext
|
||||
|
||||
A new Flutter plugin project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter
|
||||
[plug-in package](https://flutter.dev/developing-packages/),
|
||||
a specialized package that includes platform-specific implementation code for
|
||||
Android and/or iOS.
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://flutter.dev/docs), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- android/**
|
||||
- ios/**
|
||||
- web/**
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
include: ../../lint_options.yaml
|
||||
@@ -1,2 +0,0 @@
|
||||
export 'window_ext_listener.dart';
|
||||
export 'window_ext_manager.dart';
|
||||
@@ -1,5 +0,0 @@
|
||||
abstract mixin class WindowExtListener {
|
||||
void onTaskbarCreated() {}
|
||||
|
||||
void onShouldTerminate() {}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'window_ext_listener.dart';
|
||||
|
||||
class WindowExtManager {
|
||||
WindowExtManager._() {
|
||||
_channel.setMethodCallHandler(_methodCallHandler);
|
||||
}
|
||||
|
||||
static final WindowExtManager instance = WindowExtManager._();
|
||||
|
||||
final MethodChannel _channel = const MethodChannel('window_ext');
|
||||
|
||||
final ObserverList<WindowExtListener> _listeners =
|
||||
ObserverList<WindowExtListener>();
|
||||
|
||||
Future<void> _methodCallHandler(MethodCall call) async {
|
||||
for (final WindowExtListener listener in List.of(_listeners)) {
|
||||
switch (call.method) {
|
||||
case 'taskbarCreated':
|
||||
listener.onTaskbarCreated();
|
||||
break;
|
||||
case 'shouldTerminate':
|
||||
listener.onShouldTerminate();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool get hasListeners {
|
||||
return _listeners.isNotEmpty;
|
||||
}
|
||||
|
||||
void addListener(WindowExtListener listener) {
|
||||
_listeners.add(listener);
|
||||
}
|
||||
|
||||
void removeListener(WindowExtListener listener) {
|
||||
_listeners.remove(listener);
|
||||
}
|
||||
|
||||
Future<void> setWindowCornerPreference({required bool round}) async {
|
||||
await _channel.invokeMethod('setWindowCornerPreference', {
|
||||
'round': round,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final windowExtManager = WindowExtManager.instance;
|
||||
@@ -1,26 +0,0 @@
|
||||
// swift-tools-version: 5.9
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "window_ext",
|
||||
platforms: [
|
||||
.macOS("10.13")
|
||||
],
|
||||
products: [
|
||||
.library(name: "window-ext", targets: ["window_ext"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "FlutterFramework", path: "../FlutterFramework")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "window_ext",
|
||||
dependencies: [
|
||||
.product(name: "FlutterFramework", package: "FlutterFramework")
|
||||
],
|
||||
path: "Sources/window_ext"
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
import Cocoa
|
||||
import FlutterMacOS
|
||||
|
||||
public class WindowExtPlugin: NSObject, FlutterPlugin {
|
||||
public static var instance: WindowExtPlugin?
|
||||
|
||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(name: "window_ext", binaryMessenger: registrar.messenger)
|
||||
instance = WindowExtPlugin(registrar, channel)
|
||||
registrar.addMethodCallDelegate(instance!, channel: channel)
|
||||
}
|
||||
|
||||
private var registrar: FlutterPluginRegistrar!
|
||||
private var channel: FlutterMethodChannel!
|
||||
|
||||
public init(_ registrar: FlutterPluginRegistrar, _ channel: FlutterMethodChannel) {
|
||||
super.init()
|
||||
self.registrar = registrar
|
||||
self.channel = channel
|
||||
}
|
||||
|
||||
public func handleShouldTerminate() {
|
||||
channel.invokeMethod("shouldTerminate", arguments: nil)
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
name: window_ext
|
||||
description: "A new Flutter plugin project."
|
||||
version: 0.0.1
|
||||
homepage:
|
||||
|
||||
environment:
|
||||
sdk: '>=3.4.4 <4.0.0'
|
||||
flutter: '>=3.3.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
plugin_platform_interface: ^2.0.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
# This section identifies this Flutter project as a plugin project.
|
||||
# The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.)
|
||||
# which should be registered in the plugin registry. This is required for
|
||||
# using method channels.
|
||||
# The Android 'package' specifies package in which the registered class is.
|
||||
# This is required for using method channels on Android.
|
||||
# The 'ffiPlugin' specifies that native code should be built and bundled.
|
||||
# This is required for using `dart:ffi`.
|
||||
# All these are used by the tooling to maintain consistency when
|
||||
# adding or updating assets for this project.
|
||||
plugin:
|
||||
platforms:
|
||||
windows:
|
||||
pluginClass: WindowExtPluginCApi
|
||||
macos:
|
||||
pluginClass: WindowExtPlugin
|
||||
|
||||
# To add assets to your plugin package, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
#
|
||||
# For details regarding assets in packages, see
|
||||
# https://flutter.dev/assets-and-images/#from-packages
|
||||
#
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/assets-and-images/#resolution-aware
|
||||
|
||||
# To add custom fonts to your plugin package, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts in packages, see
|
||||
# https://flutter.dev/custom-fonts/#from-packages
|
||||
@@ -1,54 +0,0 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:window_ext/window_ext.dart';
|
||||
|
||||
const MethodChannel _channel = MethodChannel('window_ext');
|
||||
const StandardMethodCodec _codec = StandardMethodCodec();
|
||||
|
||||
Future<void> _emit(String method) {
|
||||
return TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.handlePlatformMessage(
|
||||
_channel.name,
|
||||
_codec.encodeMethodCall(MethodCall(method)),
|
||||
(_) {},
|
||||
);
|
||||
}
|
||||
|
||||
class _RemoveOnEventListener with WindowExtListener {
|
||||
int taskbarCreatedCount = 0;
|
||||
|
||||
@override
|
||||
void onTaskbarCreated() {
|
||||
taskbarCreatedCount++;
|
||||
windowExtManager.removeListener(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _CountingListener with WindowExtListener {
|
||||
int taskbarCreatedCount = 0;
|
||||
|
||||
@override
|
||||
void onTaskbarCreated() {
|
||||
taskbarCreatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('a listener removing itself still reaches the later ones', () async {
|
||||
final remover = _RemoveOnEventListener();
|
||||
final counter = _CountingListener();
|
||||
windowExtManager.addListener(remover);
|
||||
windowExtManager.addListener(counter);
|
||||
addTearDown(() {
|
||||
windowExtManager.removeListener(remover);
|
||||
windowExtManager.removeListener(counter);
|
||||
});
|
||||
|
||||
await _emit('taskbarCreated');
|
||||
|
||||
expect(remover.taskbarCreatedCount, 1);
|
||||
expect(counter.taskbarCreatedCount, 1);
|
||||
});
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
flutter/
|
||||
|
||||
# Visual Studio user-specific files.
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# Visual Studio build-related files.
|
||||
x64/
|
||||
x86/
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!*.[Cc]ache/
|
||||
@@ -1,100 +0,0 @@
|
||||
# The Flutter tooling requires that developers have a version of Visual Studio
|
||||
# installed that includes CMake 3.14 or later. You should not increase this
|
||||
# version, as doing so will cause the plugin to fail to compile for some
|
||||
# customers of the plugin.
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
|
||||
# Project-level configuration.
|
||||
set(PROJECT_NAME "window_ext")
|
||||
project(${PROJECT_NAME} LANGUAGES CXX)
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
cmake_policy(VERSION 3.14...3.25)
|
||||
|
||||
# This value is used when generating builds using this plugin, so it must
|
||||
# not be changed
|
||||
set(PLUGIN_NAME "window_ext_plugin")
|
||||
|
||||
# Any new source files that you add to the plugin should be added here.
|
||||
list(APPEND PLUGIN_SOURCES
|
||||
"window_ext_plugin.cpp"
|
||||
"window_ext_plugin.h"
|
||||
)
|
||||
|
||||
# Define the plugin library target. Its name must not be changed (see comment
|
||||
# on PLUGIN_NAME above).
|
||||
add_library(${PLUGIN_NAME} SHARED
|
||||
"include/window_ext/window_ext_plugin_c_api.h"
|
||||
"window_ext_plugin_c_api.cpp"
|
||||
${PLUGIN_SOURCES}
|
||||
)
|
||||
|
||||
# Apply a standard set of build settings that are configured in the
|
||||
# application-level CMakeLists.txt. This can be removed for plugins that want
|
||||
# full control over build settings.
|
||||
apply_standard_settings(${PLUGIN_NAME})
|
||||
|
||||
# Symbols are hidden by default to reduce the chance of accidental conflicts
|
||||
# between plugins. This should not be removed; any symbols that should be
|
||||
# exported should be explicitly exported with the FLUTTER_PLUGIN_EXPORT macro.
|
||||
set_target_properties(${PLUGIN_NAME} PROPERTIES
|
||||
CXX_VISIBILITY_PRESET hidden)
|
||||
target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL)
|
||||
|
||||
# Source include directories and library dependencies. Add any plugin-specific
|
||||
# dependencies here.
|
||||
target_include_directories(${PLUGIN_NAME} INTERFACE
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/include")
|
||||
target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin dwmapi)
|
||||
|
||||
# List of absolute paths to libraries that should be bundled with the plugin.
|
||||
# This list could contain prebuilt libraries, or libraries created by an
|
||||
# external build triggered from this build file.
|
||||
set(window_ext_bundled_libraries
|
||||
""
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
# === Tests ===
|
||||
# These unit tests can be run from a terminal after building the example, or
|
||||
# from Visual Studio after opening the generated solution file.
|
||||
|
||||
# Only enable test builds when building the example (which sets this variable)
|
||||
# so that plugin clients aren't building the tests.
|
||||
if (${include_${PROJECT_NAME}_tests})
|
||||
set(TEST_RUNNER "${PROJECT_NAME}_test")
|
||||
enable_testing()
|
||||
|
||||
# Add the Google Test dependency.
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(
|
||||
googletest
|
||||
URL https://github.com/google/googletest/archive/release-1.11.0.zip
|
||||
)
|
||||
# Prevent overriding the parent project's compiler/linker settings
|
||||
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
|
||||
# Disable install commands for gtest so it doesn't end up in the bundle.
|
||||
set(INSTALL_GTEST OFF CACHE BOOL "Disable installation of googletest" FORCE)
|
||||
FetchContent_MakeAvailable(googletest)
|
||||
|
||||
# The plugin's C API is not very useful for unit testing, so build the sources
|
||||
# directly into the test binary rather than using the DLL.
|
||||
add_executable(${TEST_RUNNER}
|
||||
test/window_ext_plugin_test.cpp
|
||||
${PLUGIN_SOURCES}
|
||||
)
|
||||
apply_standard_settings(${TEST_RUNNER})
|
||||
target_include_directories(${TEST_RUNNER} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
target_link_libraries(${TEST_RUNNER} PRIVATE flutter_wrapper_plugin)
|
||||
target_link_libraries(${TEST_RUNNER} PRIVATE gtest_main gmock)
|
||||
# flutter_wrapper_plugin has link dependencies on the Flutter DLL.
|
||||
add_custom_command(TARGET ${TEST_RUNNER} POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${FLUTTER_LIBRARY}" $<TARGET_FILE_DIR:${TEST_RUNNER}>
|
||||
)
|
||||
|
||||
# Enable automatic test discovery.
|
||||
include(GoogleTest)
|
||||
gtest_discover_tests(${TEST_RUNNER})
|
||||
endif()
|
||||
@@ -1,23 +0,0 @@
|
||||
#ifndef FLUTTER_PLUGIN_WINDOW_EXT_PLUGIN_C_API_H_
|
||||
#define FLUTTER_PLUGIN_WINDOW_EXT_PLUGIN_C_API_H_
|
||||
|
||||
#include <flutter_plugin_registrar.h>
|
||||
|
||||
#ifdef FLUTTER_PLUGIN_IMPL
|
||||
#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport)
|
||||
#else
|
||||
#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport)
|
||||
#endif
|
||||
|
||||
#if defined(__cplusplus)
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
FLUTTER_PLUGIN_EXPORT void WindowExtPluginCApiRegisterWithRegistrar(
|
||||
FlutterDesktopPluginRegistrarRef registrar);
|
||||
|
||||
#if defined(__cplusplus)
|
||||
} // extern "C"
|
||||
#endif
|
||||
|
||||
#endif // FLUTTER_PLUGIN_WINDOW_EXT_PLUGIN_C_API_H_
|
||||
@@ -1,43 +0,0 @@
|
||||
#include <flutter/method_call.h>
|
||||
#include <flutter/method_result_functions.h>
|
||||
#include <flutter/standard_method_codec.h>
|
||||
#include <gtest/gtest.h>
|
||||
#include <windows.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <variant>
|
||||
|
||||
#include "window_ext_plugin.h"
|
||||
|
||||
namespace window_ext {
|
||||
namespace test {
|
||||
|
||||
namespace {
|
||||
|
||||
using flutter::EncodableMap;
|
||||
using flutter::EncodableValue;
|
||||
using flutter::MethodCall;
|
||||
using flutter::MethodResultFunctions;
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(WindowExtPlugin, GetPlatformVersion) {
|
||||
WindowExtPlugin plugin;
|
||||
// Save the reply value from the success callback.
|
||||
std::string result_string;
|
||||
plugin.HandleMethodCall(
|
||||
MethodCall("getPlatformVersion", std::make_unique<EncodableValue>()),
|
||||
std::make_unique<MethodResultFunctions<>>(
|
||||
[&result_string](const EncodableValue* result) {
|
||||
result_string = std::get<std::string>(*result);
|
||||
},
|
||||
nullptr, nullptr));
|
||||
|
||||
// Since the exact string varies by host, just ensure that it's a string
|
||||
// with the expected format.
|
||||
EXPECT_TRUE(result_string.rfind("Windows ", 0) == 0);
|
||||
}
|
||||
|
||||
} // namespace test
|
||||
} // namespace window_ext
|
||||
@@ -1,116 +0,0 @@
|
||||
#include "window_ext_plugin.h"
|
||||
|
||||
// This must be included before many other Windows headers.
|
||||
#include <windows.h>
|
||||
|
||||
// For getPlatformVersion; remove unless needed for your plugin implementation.
|
||||
#include <VersionHelpers.h>
|
||||
|
||||
#include <dwmapi.h>
|
||||
#include <flutter/method_channel.h>
|
||||
#include <flutter/plugin_registrar_windows.h>
|
||||
#include <flutter/standard_method_codec.h>
|
||||
|
||||
#include <memory>
|
||||
#include <sstream>
|
||||
|
||||
#ifndef DWMWA_WINDOW_CORNER_PREFERENCE
|
||||
#define DWMWA_WINDOW_CORNER_PREFERENCE 33
|
||||
#endif
|
||||
|
||||
#ifndef DWMWCP_DONOTROUND
|
||||
#define DWMWCP_DONOTROUND 1
|
||||
#endif
|
||||
|
||||
#ifndef DWMWCP_ROUND
|
||||
#define DWMWCP_ROUND 2
|
||||
#endif
|
||||
|
||||
namespace window_ext {
|
||||
|
||||
|
||||
std::unique_ptr<
|
||||
flutter::MethodChannel<flutter::EncodableValue>,
|
||||
std::default_delete<flutter::MethodChannel<flutter::EncodableValue>>>
|
||||
channel = nullptr;
|
||||
|
||||
|
||||
// static
|
||||
void WindowExtPlugin::RegisterWithRegistrar(
|
||||
flutter::PluginRegistrarWindows *registrar) {
|
||||
channel =
|
||||
std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
|
||||
registrar->messenger(), "window_ext",
|
||||
&flutter::StandardMethodCodec::GetInstance());
|
||||
|
||||
auto plugin = std::make_unique<WindowExtPlugin>(registrar);
|
||||
|
||||
channel->SetMethodCallHandler(
|
||||
[plugin_pointer = plugin.get()](const auto &call, auto result) {
|
||||
plugin_pointer->HandleMethodCall(call, std::move(result));
|
||||
});
|
||||
|
||||
registrar->AddPlugin(std::move(plugin));
|
||||
}
|
||||
|
||||
WindowExtPlugin::WindowExtPlugin(flutter::PluginRegistrarWindows* registrar)
|
||||
: registrar(registrar) {
|
||||
WM_TASKBARCREATED = RegisterWindowMessage(TEXT("TaskbarCreated"));
|
||||
window_proc_id = registrar->RegisterTopLevelWindowProcDelegate(
|
||||
[this](HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
|
||||
return HandleWindowProc(hwnd, message, wparam, lparam);
|
||||
});
|
||||
}
|
||||
|
||||
WindowExtPlugin::~WindowExtPlugin() {
|
||||
registrar->UnregisterTopLevelWindowProcDelegate(window_proc_id);
|
||||
}
|
||||
|
||||
std::optional<LRESULT> WindowExtPlugin::HandleWindowProc(HWND hWnd,
|
||||
UINT message,
|
||||
WPARAM wParam,
|
||||
LPARAM lParam) {
|
||||
std::optional<LRESULT> result;
|
||||
if(message == WM_TASKBARCREATED){
|
||||
channel -> InvokeMethod("taskbarCreated", std::make_unique<flutter::EncodableValue>());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
void WindowExtPlugin::HandleMethodCall(
|
||||
const flutter::MethodCall<flutter::EncodableValue> &method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
|
||||
if (method_call.method_name().compare("getPlatformVersion") == 0) {
|
||||
std::ostringstream version_stream;
|
||||
version_stream << "Windows ";
|
||||
if (IsWindows10OrGreater()) {
|
||||
version_stream << "10+";
|
||||
} else if (IsWindows8OrGreater()) {
|
||||
version_stream << "8";
|
||||
} else if (IsWindows7OrGreater()) {
|
||||
version_stream << "7";
|
||||
}
|
||||
result->Success(flutter::EncodableValue(version_stream.str()));
|
||||
} else if (method_call.method_name().compare("setWindowCornerPreference") == 0) {
|
||||
HWND hWnd = ::GetAncestor(registrar->GetView()->GetNativeWindow(), GA_ROOT);
|
||||
if (hWnd) {
|
||||
const auto *args = std::get_if<flutter::EncodableMap>(method_call.arguments());
|
||||
if (args) {
|
||||
auto round_it = args->find(flutter::EncodableValue("round"));
|
||||
if (round_it != args->end()) {
|
||||
bool round = std::get<bool>(round_it->second);
|
||||
DWORD preference = round ? DWMWCP_ROUND : DWMWCP_DONOTROUND;
|
||||
DwmSetWindowAttribute(hWnd, DWMWA_WINDOW_CORNER_PREFERENCE,
|
||||
&preference, sizeof(preference));
|
||||
}
|
||||
}
|
||||
}
|
||||
result->Success();
|
||||
} else {
|
||||
result->NotImplemented();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace window_ext
|
||||
@@ -1,41 +0,0 @@
|
||||
#ifndef FLUTTER_PLUGIN_WINDOW_EXT_PLUGIN_H_
|
||||
#define FLUTTER_PLUGIN_WINDOW_EXT_PLUGIN_H_
|
||||
|
||||
#include <flutter/method_channel.h>
|
||||
#include <flutter/plugin_registrar_windows.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace window_ext {
|
||||
|
||||
class WindowExtPlugin : public flutter::Plugin {
|
||||
public:
|
||||
static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar);
|
||||
|
||||
|
||||
WindowExtPlugin(flutter::PluginRegistrarWindows *registrar);
|
||||
virtual ~WindowExtPlugin();
|
||||
|
||||
// Disallow copy and assign.
|
||||
WindowExtPlugin(const WindowExtPlugin&) = delete;
|
||||
WindowExtPlugin& operator=(const WindowExtPlugin&) = delete;
|
||||
|
||||
// Called when a method is called on this plugin's channel from Dart.
|
||||
void HandleMethodCall(
|
||||
const flutter::MethodCall<flutter::EncodableValue> &method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
|
||||
|
||||
std::optional<LRESULT> HandleWindowProc(
|
||||
HWND hwnd,
|
||||
UINT message,
|
||||
WPARAM wparam,
|
||||
LPARAM lparam);
|
||||
|
||||
int window_proc_id = -1;
|
||||
UINT WM_TASKBARCREATED = 0;
|
||||
flutter::PluginRegistrarWindows *registrar;
|
||||
};
|
||||
|
||||
} // namespace window_ext
|
||||
|
||||
#endif // FLUTTER_PLUGIN_WINDOW_EXT_PLUGIN_H_
|
||||
@@ -1,12 +0,0 @@
|
||||
#include "include/window_ext/window_ext_plugin_c_api.h"
|
||||
|
||||
#include <flutter/plugin_registrar_windows.h>
|
||||
|
||||
#include "window_ext_plugin.h"
|
||||
|
||||
void WindowExtPluginCApiRegisterWithRegistrar(
|
||||
FlutterDesktopPluginRegistrarRef registrar) {
|
||||
window_ext::WindowExtPlugin::RegisterWithRegistrar(
|
||||
flutter::PluginRegistrarManager::GetInstance()
|
||||
->GetRegistrar<flutter::PluginRegistrarWindows>(registrar));
|
||||
}
|
||||
+4
-11
@@ -237,10 +237,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: connectivity_plus
|
||||
sha256: "762c99f890ca8bf87f7337236f99edd42793843bc6c3631da294a76653a54bd0"
|
||||
sha256: cad0e811a289ea2a941119dc483c204ec1684cbb9a8fc7351fe4a230b8313160
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.3.1"
|
||||
version: "7.2.0"
|
||||
connectivity_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1806,19 +1806,12 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
window_ext:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "plugins/window_ext"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
window_manager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "packages/window_manager"
|
||||
ref: "5a489acfe5799c38909e3e96abd11b78354f1809"
|
||||
resolved-ref: "5a489acfe5799c38909e3e96abd11b78354f1809"
|
||||
ref: "v0.5.1-flclash.1"
|
||||
resolved-ref: "207a99ca9dbaf0585e9a337eacc4fa4848809984"
|
||||
url: "https://github.com/chen08209/window_manager"
|
||||
source: git
|
||||
version: "0.5.1"
|
||||
|
||||
+2
-4
@@ -16,13 +16,11 @@ dependencies:
|
||||
window_manager:
|
||||
git:
|
||||
url: https://github.com/chen08209/window_manager
|
||||
ref: 5a489acfe5799c38909e3e96abd11b78354f1809
|
||||
ref: v0.5.1-flclash.1
|
||||
path: packages/window_manager
|
||||
dynamic_color: ^1.9.0
|
||||
proxy:
|
||||
path: plugins/proxy
|
||||
window_ext:
|
||||
path: plugins/window_ext
|
||||
launch_at_startup:
|
||||
git:
|
||||
url: https://github.com/chen08209/launch_at_startup.git
|
||||
@@ -54,7 +52,7 @@ dependencies:
|
||||
hotkey_manager: ^0.2.3
|
||||
uni_platform: ^0.1.3
|
||||
device_info_plus: ^13.2.0
|
||||
connectivity_plus: ^7.3.1
|
||||
connectivity_plus: 7.2.0
|
||||
screen_retriever: ^0.2.1
|
||||
defer_pointer: ^0.0.2
|
||||
flutter_riverpod: ^3.3.2
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import 'package:fl_clash/common/boot_guard.dart';
|
||||
import 'package:fl_clash/common/boot_record.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
class _RecordStore {
|
||||
BootRecord? record;
|
||||
var writes = 0;
|
||||
|
||||
Future<BootRecord?> read() async => record;
|
||||
|
||||
Future<void> write(BootRecord value) async {
|
||||
record = value;
|
||||
writes++;
|
||||
}
|
||||
}
|
||||
|
||||
BootGuard _guard(
|
||||
_RecordStore store, {
|
||||
bool supported = true,
|
||||
AppExitInfo? exitInfo,
|
||||
bool crashReported = false,
|
||||
int now = 5000,
|
||||
void Function()? onCrashProbe,
|
||||
}) {
|
||||
return BootGuard(
|
||||
supported: supported,
|
||||
readRecord: store.read,
|
||||
writeRecord: store.write,
|
||||
readExitInfo: () async => exitInfo,
|
||||
readCrashReport: () async {
|
||||
onCrashProbe?.call();
|
||||
return crashReported;
|
||||
},
|
||||
now: () => now,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'a first launch records a starting stage and recovers nothing',
|
||||
() async {
|
||||
final store = _RecordStore();
|
||||
final guard = _guard(store);
|
||||
|
||||
final decision = await guard.evaluate(
|
||||
profileId: 7,
|
||||
crashlyticsEnabled: false,
|
||||
);
|
||||
|
||||
expect(decision.recovery, BootRecovery.none);
|
||||
expect(store.record?.stage, BootStage.starting);
|
||||
expect(store.record?.profileId, 7);
|
||||
expect(store.record?.startedAt, 5000);
|
||||
expect(store.record?.failureCount, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'the Crashlytics probe stays out of the path unless it is enabled',
|
||||
() async {
|
||||
final store = _RecordStore();
|
||||
var probes = 0;
|
||||
final guard = _guard(store, onCrashProbe: () => probes++);
|
||||
|
||||
await guard.evaluate(profileId: 7, crashlyticsEnabled: false);
|
||||
expect(probes, 0);
|
||||
|
||||
await guard.evaluate(profileId: 7, crashlyticsEnabled: true);
|
||||
expect(probes, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test('an interrupted launch is carried into the next boot record', () async {
|
||||
final store = _RecordStore()
|
||||
..record = const BootRecord(
|
||||
stage: BootStage.starting,
|
||||
profileId: 7,
|
||||
startedAt: 1000,
|
||||
);
|
||||
final guard = _guard(store);
|
||||
|
||||
final decision = await guard.evaluate(
|
||||
profileId: 7,
|
||||
crashlyticsEnabled: false,
|
||||
);
|
||||
|
||||
expect(decision.recovery, BootRecovery.skipAutoSetup);
|
||||
expect(store.record?.failureCount, 1);
|
||||
expect(store.record?.profileId, 7);
|
||||
expect(store.record?.lastFailedProfileId, 7);
|
||||
});
|
||||
|
||||
test('clearing the profile also drops it from the new boot record', () async {
|
||||
final store = _RecordStore()
|
||||
..record = const BootRecord(
|
||||
stage: BootStage.starting,
|
||||
profileId: 7,
|
||||
startedAt: 1000,
|
||||
failureCount: 1,
|
||||
);
|
||||
final guard = _guard(store);
|
||||
|
||||
final decision = await guard.evaluate(
|
||||
profileId: 7,
|
||||
crashlyticsEnabled: false,
|
||||
);
|
||||
|
||||
expect(decision.recovery, BootRecovery.clearProfile);
|
||||
expect(store.record?.profileId, isNull);
|
||||
expect(store.record?.lastFailedProfileId, 7);
|
||||
expect(store.record?.failureCount, 2);
|
||||
});
|
||||
|
||||
test('a consumed exit timestamp is remembered across launches', () async {
|
||||
final store = _RecordStore();
|
||||
final guard = _guard(
|
||||
store,
|
||||
exitInfo: const AppExitInfo(
|
||||
reason: AppExitReason.userRequested,
|
||||
timestamp: 4000,
|
||||
),
|
||||
);
|
||||
|
||||
await guard.evaluate(profileId: 7, crashlyticsEnabled: false);
|
||||
|
||||
expect(store.record?.handledExitAt, 4000);
|
||||
|
||||
final next = _guard(
|
||||
store,
|
||||
exitInfo: const AppExitInfo(
|
||||
reason: AppExitReason.userRequested,
|
||||
timestamp: 4000,
|
||||
),
|
||||
now: 6000,
|
||||
);
|
||||
final decision = await next.evaluate(
|
||||
profileId: 7,
|
||||
crashlyticsEnabled: false,
|
||||
);
|
||||
|
||||
expect(decision.recovery, BootRecovery.skipAutoSetup);
|
||||
expect(store.record?.handledExitAt, 4000);
|
||||
});
|
||||
|
||||
test('a completed launch resets the failure count', () async {
|
||||
final store = _RecordStore();
|
||||
final guard = _guard(store);
|
||||
await guard.evaluate(profileId: 7, crashlyticsEnabled: false);
|
||||
|
||||
await guard.markRunning();
|
||||
|
||||
expect(store.record?.stage, BootStage.running);
|
||||
expect(store.record?.failureCount, 0);
|
||||
});
|
||||
|
||||
test('a degraded launch keeps its failure count until a clean run', () async {
|
||||
final store = _RecordStore()
|
||||
..record = const BootRecord(
|
||||
stage: BootStage.starting,
|
||||
profileId: 7,
|
||||
startedAt: 1000,
|
||||
);
|
||||
final guard = _guard(store);
|
||||
await guard.evaluate(profileId: 7, crashlyticsEnabled: false);
|
||||
|
||||
await guard.markRunning();
|
||||
|
||||
expect(store.record?.stage, BootStage.running);
|
||||
expect(store.record?.failureCount, 1);
|
||||
});
|
||||
|
||||
test('a clean exit closes the record and clears the failure count', () async {
|
||||
final store = _RecordStore()
|
||||
..record = const BootRecord(
|
||||
stage: BootStage.starting,
|
||||
profileId: 7,
|
||||
startedAt: 1000,
|
||||
failureCount: 1,
|
||||
lastFailedProfileId: 7,
|
||||
handledExitAt: 900,
|
||||
);
|
||||
final guard = _guard(store);
|
||||
|
||||
await guard.markClosed();
|
||||
|
||||
expect(store.record?.stage, isNull);
|
||||
expect(store.record?.failureCount, 0);
|
||||
expect(store.record?.lastFailedProfileId, 7);
|
||||
expect(store.record?.handledExitAt, 900);
|
||||
});
|
||||
|
||||
test('markRunning and markClosed do nothing without a record', () async {
|
||||
final store = _RecordStore();
|
||||
final guard = _guard(store);
|
||||
|
||||
await guard.markRunning();
|
||||
await guard.markClosed();
|
||||
|
||||
expect(store.writes, 0);
|
||||
expect(store.record, isNull);
|
||||
});
|
||||
|
||||
test('an unsupported platform never reads, writes or recovers', () async {
|
||||
final store = _RecordStore()
|
||||
..record = const BootRecord(
|
||||
stage: BootStage.starting,
|
||||
profileId: 7,
|
||||
startedAt: 1000,
|
||||
failureCount: 1,
|
||||
);
|
||||
var probes = 0;
|
||||
final guard = _guard(
|
||||
store,
|
||||
supported: false,
|
||||
crashReported: true,
|
||||
onCrashProbe: () => probes++,
|
||||
);
|
||||
|
||||
final decision = await guard.evaluate(
|
||||
profileId: 7,
|
||||
crashlyticsEnabled: true,
|
||||
);
|
||||
await guard.markRunning();
|
||||
await guard.markClosed();
|
||||
|
||||
expect(decision.recovery, BootRecovery.none);
|
||||
expect(guard.decision.recovery, BootRecovery.none);
|
||||
expect(probes, 0);
|
||||
expect(store.writes, 0);
|
||||
expect(store.record?.stage, BootStage.starting);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fl_clash/common/boot_record.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
const _startedAt = 1000;
|
||||
|
||||
BootRecord _starting({
|
||||
int failureCount = 0,
|
||||
int? profileId = 7,
|
||||
int handledExitAt = 0,
|
||||
}) {
|
||||
return BootRecord(
|
||||
stage: BootStage.starting,
|
||||
profileId: profileId,
|
||||
startedAt: _startedAt,
|
||||
failureCount: failureCount,
|
||||
handledExitAt: handledExitAt,
|
||||
);
|
||||
}
|
||||
|
||||
AppExitInfo _exit(AppExitReason reason, {int timestamp = _startedAt + 500}) {
|
||||
return AppExitInfo(reason: reason, timestamp: timestamp);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('AppExitReason', () {
|
||||
test('maps the ApplicationExitInfo codes it is given', () {
|
||||
expect(AppExitReason.fromCode(4), AppExitReason.crash);
|
||||
expect(AppExitReason.fromCode(5), AppExitReason.crashNative);
|
||||
expect(AppExitReason.fromCode(6), AppExitReason.anr);
|
||||
expect(AppExitReason.fromCode(10), AppExitReason.userRequested);
|
||||
expect(AppExitReason.fromCode(null), AppExitReason.unknown);
|
||||
expect(AppExitReason.fromCode('4'), AppExitReason.unknown);
|
||||
expect(AppExitReason.fromCode(99), AppExitReason.unknown);
|
||||
});
|
||||
|
||||
test('separates crashes, external stops and undecidable reasons', () {
|
||||
expect(AppExitReason.crashNative.isCrash, isTrue);
|
||||
expect(AppExitReason.initializationFailure.isCrash, isTrue);
|
||||
expect(AppExitReason.crash.isExternalStop, isFalse);
|
||||
expect(AppExitReason.lowMemory.isExternalStop, isTrue);
|
||||
expect(AppExitReason.signaled.isExternalStop, isTrue);
|
||||
expect(AppExitReason.unknown.isCrash, isFalse);
|
||||
expect(AppExitReason.unknown.isExternalStop, isFalse);
|
||||
expect(AppExitReason.other.isExternalStop, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('AppExitInfo', () {
|
||||
test('parses a platform map', () {
|
||||
final info = AppExitInfo.fromJson({
|
||||
'reason': 6,
|
||||
'timestamp': 42,
|
||||
'description': 'anr',
|
||||
});
|
||||
|
||||
expect(info?.reason, AppExitReason.anr);
|
||||
expect(info?.timestamp, 42);
|
||||
expect(info?.description, 'anr');
|
||||
});
|
||||
|
||||
test('rejects payloads without a usable timestamp', () {
|
||||
expect(AppExitInfo.fromJson(null), isNull);
|
||||
expect(AppExitInfo.fromJson('crash'), isNull);
|
||||
expect(AppExitInfo.fromJson({'reason': 4}), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('BootRecord', () {
|
||||
test('round-trips through JSON', () {
|
||||
const record = BootRecord(
|
||||
stage: BootStage.running,
|
||||
profileId: 3,
|
||||
startedAt: 12,
|
||||
failureCount: 2,
|
||||
lastFailedProfileId: 9,
|
||||
handledExitAt: 34,
|
||||
);
|
||||
|
||||
expect(BootRecord.fromJson(json.decode(json.encode(record))), record);
|
||||
});
|
||||
|
||||
test('falls back to a closed record when fields are missing or wrong', () {
|
||||
final record = BootRecord.fromJson({'stage': 'gone', 'profileId': '3'});
|
||||
|
||||
expect(record, const BootRecord());
|
||||
expect(BootRecord.fromJson('boot'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveBootDecision', () {
|
||||
test('does nothing when the last run reached the running stage', () {
|
||||
final decision = resolveBootDecision(
|
||||
record: const BootRecord(stage: BootStage.running, profileId: 7),
|
||||
exitInfo: _exit(AppExitReason.crash),
|
||||
crashReported: true,
|
||||
);
|
||||
|
||||
expect(decision.recovery, BootRecovery.none);
|
||||
expect(decision.failureCount, 0);
|
||||
});
|
||||
|
||||
test('does nothing on a first launch', () {
|
||||
final decision = resolveBootDecision(
|
||||
record: null,
|
||||
exitInfo: null,
|
||||
crashReported: true,
|
||||
);
|
||||
|
||||
expect(decision.recovery, BootRecovery.none);
|
||||
});
|
||||
|
||||
test('skips automatic setup after one interrupted launch', () {
|
||||
final decision = resolveBootDecision(
|
||||
record: _starting(),
|
||||
exitInfo: null,
|
||||
crashReported: false,
|
||||
);
|
||||
|
||||
expect(decision.recovery, BootRecovery.skipAutoSetup);
|
||||
expect(decision.failureCount, 1);
|
||||
expect(decision.failedProfileId, 7);
|
||||
expect(decision.crashConfirmed, isFalse);
|
||||
});
|
||||
|
||||
test('clears the profile once launches fail twice in a row', () {
|
||||
final decision = resolveBootDecision(
|
||||
record: _starting(failureCount: 1),
|
||||
exitInfo: _exit(AppExitReason.crashNative),
|
||||
crashReported: false,
|
||||
);
|
||||
|
||||
expect(decision.recovery, BootRecovery.clearProfile);
|
||||
expect(decision.failureCount, 2);
|
||||
expect(decision.crashConfirmed, isTrue);
|
||||
expect(decision.exitReason, AppExitReason.crashNative);
|
||||
});
|
||||
|
||||
test('keeps skipping when there is no profile left to clear', () {
|
||||
final decision = resolveBootDecision(
|
||||
record: _starting(failureCount: 3, profileId: null),
|
||||
exitInfo: null,
|
||||
crashReported: false,
|
||||
);
|
||||
|
||||
expect(decision.recovery, BootRecovery.skipAutoSetup);
|
||||
expect(decision.failureCount, 4);
|
||||
});
|
||||
|
||||
test('an external stop is not counted as a failed launch', () {
|
||||
for (final reason in [
|
||||
AppExitReason.userRequested,
|
||||
AppExitReason.userStopped,
|
||||
AppExitReason.lowMemory,
|
||||
AppExitReason.signaled,
|
||||
AppExitReason.exitSelf,
|
||||
]) {
|
||||
final decision = resolveBootDecision(
|
||||
record: _starting(failureCount: 1),
|
||||
exitInfo: _exit(reason),
|
||||
crashReported: true,
|
||||
);
|
||||
|
||||
expect(decision.recovery, BootRecovery.none, reason: reason.name);
|
||||
expect(decision.failureCount, 0, reason: reason.name);
|
||||
}
|
||||
});
|
||||
|
||||
test('an already consumed exit record cannot veto or confirm again', () {
|
||||
final decision = resolveBootDecision(
|
||||
record: _starting(handledExitAt: _startedAt + 600),
|
||||
exitInfo: _exit(AppExitReason.userRequested),
|
||||
crashReported: false,
|
||||
);
|
||||
|
||||
expect(decision.recovery, BootRecovery.skipAutoSetup);
|
||||
expect(decision.exitReason, isNull);
|
||||
});
|
||||
|
||||
test('an exit older than the interrupted run is ignored', () {
|
||||
final decision = resolveBootDecision(
|
||||
record: _starting(),
|
||||
exitInfo: _exit(AppExitReason.userRequested, timestamp: _startedAt - 1),
|
||||
crashReported: false,
|
||||
);
|
||||
|
||||
expect(decision.recovery, BootRecovery.skipAutoSetup);
|
||||
expect(decision.exitReason, isNull);
|
||||
});
|
||||
|
||||
test('a reported crash confirms an interruption it cannot cause', () {
|
||||
final skipped = resolveBootDecision(
|
||||
record: _starting(),
|
||||
exitInfo: null,
|
||||
crashReported: true,
|
||||
);
|
||||
final clean = resolveBootDecision(
|
||||
record: const BootRecord(),
|
||||
exitInfo: null,
|
||||
crashReported: true,
|
||||
);
|
||||
|
||||
expect(skipped.crashConfirmed, isTrue);
|
||||
expect(skipped.recovery, BootRecovery.skipAutoSetup);
|
||||
expect(clean.recovery, BootRecovery.none);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fl_clash/common/boot_record.dart';
|
||||
import 'package:fl_clash/common/constant.dart';
|
||||
import 'package:fl_clash/common/preferences.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
@@ -125,4 +126,31 @@ void main() {
|
||||
test('isInit resolves true once shared preferences are available', () async {
|
||||
expect(await preferences.isInit, isTrue);
|
||||
});
|
||||
|
||||
group('boot record', () {
|
||||
test('getBootRecord returns null when nothing is stored', () async {
|
||||
expect(await preferences.getBootRecord(), isNull);
|
||||
});
|
||||
|
||||
test('saveBootRecord then getBootRecord round-trips the record', () async {
|
||||
const record = BootRecord(
|
||||
stage: BootStage.starting,
|
||||
profileId: 5,
|
||||
startedAt: 111,
|
||||
failureCount: 1,
|
||||
lastFailedProfileId: 4,
|
||||
handledExitAt: 99,
|
||||
);
|
||||
|
||||
await preferences.saveBootRecord(record);
|
||||
|
||||
expect(await preferences.getBootRecord(), record);
|
||||
});
|
||||
|
||||
test('getBootRecord survives a corrupt entry', () async {
|
||||
await store.setString(bootRecordKey, 'not json');
|
||||
|
||||
expect(await preferences.getBootRecord(), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ void main() {
|
||||
|
||||
late List<MethodCall> calls;
|
||||
late ProviderContainer container;
|
||||
late AppTray tray;
|
||||
|
||||
late Directory root;
|
||||
|
||||
@@ -97,6 +98,7 @@ void main() {
|
||||
setUp(() {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.macOS;
|
||||
calls = [];
|
||||
tray = AppTray.forPlatform(isMacOS: true, isWindows: false);
|
||||
container = ProviderContainer();
|
||||
Tray.instance.resetForTesting();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
@@ -122,8 +124,8 @@ void main() {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> update(TrayState trayState) {
|
||||
return AppTray().update(
|
||||
Future<void> update(TrayState trayState, {AppTray? on}) {
|
||||
return (on ?? tray).update(
|
||||
trayState: trayState,
|
||||
traffic: const Traffic(),
|
||||
read: container.read,
|
||||
@@ -134,7 +136,7 @@ void main() {
|
||||
'SystemAction.updateTray builds the menu without reading itself',
|
||||
() async {
|
||||
globalState.container = container;
|
||||
trayPort = AppTray();
|
||||
trayPort = tray;
|
||||
addTearDown(() => trayPort = null);
|
||||
|
||||
await container.read(systemActionProvider.notifier).updateTray();
|
||||
@@ -232,4 +234,45 @@ void main() {
|
||||
final children = (submenu['items'] as List).cast<Map<Object?, Object?>>();
|
||||
expect(children.map((item) => item['label']), contains('A'));
|
||||
});
|
||||
|
||||
group('a platform that is not macOS', () {
|
||||
late AppTray windows;
|
||||
|
||||
setUp(() {
|
||||
windows = AppTray.forPlatform(isMacOS: false, isWindows: true);
|
||||
});
|
||||
|
||||
test('gets a plain icon, no group submenus and no speed toggle', () async {
|
||||
await update(
|
||||
_trayState(
|
||||
isStart: true,
|
||||
groups: [
|
||||
const Group(
|
||||
name: 'Proxy',
|
||||
type: GroupType.Selector,
|
||||
all: [Proxy(name: 'A', type: 'Direct')],
|
||||
),
|
||||
],
|
||||
),
|
||||
on: windows,
|
||||
);
|
||||
|
||||
final arguments = showCall()!.arguments as Map;
|
||||
expect((arguments['icon'] as Map)['isTemplate'], isFalse);
|
||||
expect(
|
||||
_items(showCall()).where((item) => item['type'] == 'submenu'),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
_labels(showCall()),
|
||||
isNot(contains(currentAppLocalizations.speedStatistics)),
|
||||
);
|
||||
});
|
||||
|
||||
test('never pushes a tray title', () async {
|
||||
await windows.updateTitle(showTrayTitle: true, traffic: const Traffic());
|
||||
|
||||
expect(calls.where((call) => call.method == 'setTitle'), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,612 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:fl_clash/manager/window_manager.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
const _windowChannel = MethodChannel('window_manager');
|
||||
|
||||
const _contentKey = Key('window-header-test-content');
|
||||
|
||||
final double _windowsHeaderHeight = getWindowHeaderHeight(
|
||||
isDesktop: true,
|
||||
isMacOS: false,
|
||||
);
|
||||
|
||||
final double _macOSHeaderHeight = getWindowHeaderHeight(
|
||||
isDesktop: true,
|
||||
isMacOS: true,
|
||||
);
|
||||
|
||||
bool _hostShowsHeader({required int version, required bool isMobileView}) {
|
||||
return showsWindowHeader(
|
||||
isDesktop: system.isDesktop,
|
||||
isMacOS: system.isMacOS,
|
||||
version: version,
|
||||
isMobileView: isMobileView,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() async {
|
||||
await AppLocalizations.load(const Locale('en'));
|
||||
});
|
||||
|
||||
group('showsWindowHeader', () {
|
||||
test('Windows always draws its own header, at every width and build', () {
|
||||
for (final version in [10, 11]) {
|
||||
for (final isMobileView in [true, false]) {
|
||||
expect(
|
||||
showsWindowHeader(
|
||||
isDesktop: true,
|
||||
isMacOS: false,
|
||||
version: version,
|
||||
isMobileView: isMobileView,
|
||||
),
|
||||
isTrue,
|
||||
reason: 'windows $version mobileView=$isMobileView',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('macOS keeps its native title bar unless the view is narrow', () {
|
||||
expect(
|
||||
showsWindowHeader(
|
||||
isDesktop: true,
|
||||
isMacOS: true,
|
||||
version: 15,
|
||||
isMobileView: false,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
showsWindowHeader(
|
||||
isDesktop: true,
|
||||
isMacOS: true,
|
||||
version: 15,
|
||||
isMobileView: true,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
showsWindowHeader(
|
||||
isDesktop: true,
|
||||
isMacOS: true,
|
||||
version: 10,
|
||||
isMobileView: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('a phone never draws a window header', () {
|
||||
expect(
|
||||
showsWindowHeader(
|
||||
isDesktop: false,
|
||||
isMacOS: false,
|
||||
version: 34,
|
||||
isMobileView: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('getWindowHeaderHeight', () {
|
||||
test('Windows reserves more than macOS, mobile reserves nothing', () {
|
||||
expect(_windowsHeaderHeight, 40);
|
||||
expect(_macOSHeaderHeight, 28);
|
||||
expect(getWindowHeaderHeight(isDesktop: false, isMacOS: false), 0);
|
||||
});
|
||||
|
||||
test('the host constant is the height its own platform asks for', () {
|
||||
expect(
|
||||
kHeaderHeight,
|
||||
getWindowHeaderHeight(
|
||||
isDesktop: system.isDesktop,
|
||||
isMacOS: system.isMacOS,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('WindowHeaderBar on Windows', () {
|
||||
late ValueNotifier<bool> isPin;
|
||||
late ValueNotifier<bool> isMaximized;
|
||||
late List<String> events;
|
||||
|
||||
setUp(() {
|
||||
isPin = ValueNotifier(false);
|
||||
isMaximized = ValueNotifier(false);
|
||||
events = [];
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
isPin.dispose();
|
||||
isMaximized.dispose();
|
||||
});
|
||||
|
||||
Future<void> pumpBar(WidgetTester tester, {double? width}) async {
|
||||
if (width != null) {
|
||||
tester.view.physicalSize = Size(width, 600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
}
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(height: _windowsHeaderHeight),
|
||||
const Expanded(
|
||||
flex: 1,
|
||||
child: ColoredBox(
|
||||
key: _contentKey,
|
||||
color: Color(0xFF00FF00),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
WindowHeaderBar(
|
||||
height: _windowsHeaderHeight,
|
||||
onDragStart: () => events.add('drag'),
|
||||
onDoubleTap: () => events.add('maximize'),
|
||||
actions: WindowHeaderActions(
|
||||
isPinNotifier: isPin,
|
||||
isMaximizedNotifier: isMaximized,
|
||||
onPin: () => events.add('pin'),
|
||||
onMinimize: () => events.add('minimize'),
|
||||
onMaximize: () => events.add('maximize'),
|
||||
onClose: () => events.add('close'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Rect barRect(WidgetTester tester) =>
|
||||
tester.getRect(find.byType(WindowHeaderBar));
|
||||
|
||||
testWidgets('the bar spans the top of the window at its own height', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpBar(tester, width: 900);
|
||||
|
||||
final rect = barRect(tester);
|
||||
expect(rect.topLeft, Offset.zero);
|
||||
expect(rect.width, 900);
|
||||
expect(rect.height, _windowsHeaderHeight);
|
||||
});
|
||||
|
||||
testWidgets('the drag surface covers the whole bar', (tester) async {
|
||||
await pumpBar(tester, width: 900);
|
||||
|
||||
expect(
|
||||
tester.getRect(find.byType(GestureDetector).first),
|
||||
barRect(tester),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the caption buttons stay inside the bar', (tester) async {
|
||||
await pumpBar(tester, width: 900);
|
||||
|
||||
final bar = barRect(tester);
|
||||
final actions = tester.getRect(find.byType(WindowHeaderActions));
|
||||
|
||||
expect(
|
||||
actions.top,
|
||||
greaterThanOrEqualTo(bar.top),
|
||||
reason: 'the buttons are drawn above the top edge of the window',
|
||||
);
|
||||
expect(
|
||||
actions.bottom,
|
||||
lessThanOrEqualTo(bar.bottom),
|
||||
reason: 'the buttons spill over the page below the bar',
|
||||
);
|
||||
expect(actions.right, bar.right);
|
||||
});
|
||||
|
||||
testWidgets('every caption button is drawn inside the bar', (tester) async {
|
||||
await pumpBar(tester, width: 900);
|
||||
|
||||
final bar = barRect(tester);
|
||||
for (final icon in [
|
||||
Icons.push_pin_outlined,
|
||||
Icons.remove,
|
||||
Icons.crop_square,
|
||||
Icons.close,
|
||||
]) {
|
||||
final rect = tester.getRect(
|
||||
find.ancestor(
|
||||
of: find.byIcon(icon),
|
||||
matching: find.byType(IconButton),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
rect.top >= bar.top &&
|
||||
rect.bottom <= bar.bottom &&
|
||||
rect.left >= bar.left &&
|
||||
rect.right <= bar.right,
|
||||
isTrue,
|
||||
reason: '$icon is laid out at $rect, outside the bar $bar',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('the bar keeps its height in a narrow window', (tester) async {
|
||||
await pumpBar(tester, width: 380);
|
||||
|
||||
final rect = barRect(tester);
|
||||
expect(rect.height, _windowsHeaderHeight);
|
||||
expect(rect.width, 380);
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(
|
||||
tester.getRect(find.byType(WindowHeaderActions)).left,
|
||||
greaterThanOrEqualTo(0),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the content starts below the bar', (tester) async {
|
||||
await pumpBar(tester, width: 900);
|
||||
|
||||
expect(
|
||||
tester.getTopLeft(find.byKey(_contentKey)).dy,
|
||||
_windowsHeaderHeight,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('dragging the bar moves the window', (tester) async {
|
||||
await pumpBar(tester, width: 900);
|
||||
|
||||
await tester.timedDrag(
|
||||
find.byType(WindowHeaderBar),
|
||||
const Offset(0, 40),
|
||||
const Duration(milliseconds: 100),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(events, contains('drag'));
|
||||
});
|
||||
|
||||
testWidgets('a double tap on the bar toggles maximized', (tester) async {
|
||||
await pumpBar(tester, width: 900);
|
||||
|
||||
final bar = barRect(tester);
|
||||
await tester.tapAt(Offset(bar.width / 2, bar.center.dy));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
await tester.tapAt(Offset(bar.width / 2, bar.center.dy));
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
expect(events, contains('maximize'));
|
||||
});
|
||||
|
||||
testWidgets('each caption button reports its own press', (tester) async {
|
||||
await pumpBar(tester, width: 900);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.push_pin_outlined));
|
||||
await tester.tap(find.byIcon(Icons.remove));
|
||||
await tester.tap(find.byIcon(Icons.crop_square));
|
||||
await tester.tap(find.byIcon(Icons.close));
|
||||
await tester.pump();
|
||||
|
||||
expect(events, ['pin', 'minimize', 'maximize', 'close']);
|
||||
});
|
||||
|
||||
testWidgets('the drag surface never swallows a caption button press', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpBar(tester, width: 900);
|
||||
|
||||
final close = tester.getCenter(find.byIcon(Icons.close));
|
||||
await tester.tapAt(close);
|
||||
await tester.pump();
|
||||
|
||||
expect(events, ['close']);
|
||||
});
|
||||
});
|
||||
|
||||
group('WindowHeaderLayout above the app navigator', () {
|
||||
Future<void> pumpLayout(WidgetTester tester) async {
|
||||
final isPin = ValueNotifier(false);
|
||||
final isMaximized = ValueNotifier(false);
|
||||
addTearDown(isPin.dispose);
|
||||
addTearDown(isMaximized.dispose);
|
||||
tester.view.physicalSize = const Size(900, 600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
...GlobalMaterialLocalizations.delegates,
|
||||
],
|
||||
supportedLocales: AppLocalizations.delegate.supportedLocales,
|
||||
builder: (_, child) => WindowHeaderLayout(
|
||||
height: _windowsHeaderHeight,
|
||||
header: WindowHeaderBar(
|
||||
height: _windowsHeaderHeight,
|
||||
onDragStart: () {},
|
||||
onDoubleTap: () {},
|
||||
actions: WindowHeaderActions(
|
||||
isPinNotifier: isPin,
|
||||
isMaximizedNotifier: isMaximized,
|
||||
onPin: () {},
|
||||
onMinimize: () {},
|
||||
onMaximize: () {},
|
||||
onClose: () {},
|
||||
),
|
||||
),
|
||||
child: child!,
|
||||
),
|
||||
home: const ColoredBox(key: _contentKey, color: Color(0xFF00FF00)),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets('the tooltips on the caption buttons find an overlay', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpLayout(tester);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.byType(WindowHeaderActions), findsOneWidget);
|
||||
expect(find.byIcon(Icons.close), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a caption button shows its tooltip over the page', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpLayout(tester);
|
||||
|
||||
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
await gesture.addPointer(location: Offset.zero);
|
||||
addTearDown(gesture.removePointer);
|
||||
await gesture.moveTo(tester.getCenter(find.byIcon(Icons.close)));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(find.text(currentAppLocalizations.close), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('the page still fills the window below the header', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpLayout(tester);
|
||||
|
||||
final content = tester.getRect(find.byKey(_contentKey));
|
||||
expect(content.top, _windowsHeaderHeight);
|
||||
expect(content.width, 900);
|
||||
expect(content.bottom, 600);
|
||||
});
|
||||
});
|
||||
|
||||
group('WindowHeaderBar on macOS', () {
|
||||
// The system draws the traffic lights over the top-left of the window, so
|
||||
// whatever the header paints there is unreachable.
|
||||
const trafficLightsWidth = 80.0;
|
||||
|
||||
late List<String> events;
|
||||
|
||||
setUp(() {
|
||||
events = [];
|
||||
});
|
||||
|
||||
Future<void> pumpBar(WidgetTester tester, {required double width}) async {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(height: _macOSHeaderHeight),
|
||||
const Expanded(
|
||||
flex: 1,
|
||||
child: ColoredBox(
|
||||
key: _contentKey,
|
||||
color: Color(0xFF00FF00),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
WindowHeaderBar(
|
||||
height: _macOSHeaderHeight,
|
||||
onDragStart: () => events.add('drag'),
|
||||
onDoubleTap: () => events.add('maximize'),
|
||||
title: const Text(appName),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Rect barRect(WidgetTester tester) =>
|
||||
tester.getRect(find.byType(WindowHeaderBar));
|
||||
|
||||
testWidgets('shows the app name and no caption buttons', (tester) async {
|
||||
await pumpBar(tester, width: 900);
|
||||
|
||||
expect(find.text(appName), findsOneWidget);
|
||||
expect(find.byType(WindowHeaderActions), findsNothing);
|
||||
expect(barRect(tester).height, _macOSHeaderHeight);
|
||||
expect(
|
||||
tester.getRect(find.text(appName)).center.dy,
|
||||
barRect(tester).center.dy,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the bar spans the top of the window in a mobile view', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpBar(tester, width: 420);
|
||||
|
||||
final rect = barRect(tester);
|
||||
expect(rect.topLeft, Offset.zero);
|
||||
expect(
|
||||
rect.width,
|
||||
420,
|
||||
reason: 'the window shows through beside the app name',
|
||||
);
|
||||
expect(rect.height, _macOSHeaderHeight);
|
||||
});
|
||||
|
||||
testWidgets('the drag surface covers the whole bar', (tester) async {
|
||||
await pumpBar(tester, width: 420);
|
||||
|
||||
expect(
|
||||
tester.getRect(find.byType(GestureDetector).first),
|
||||
barRect(tester),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the app name clears the traffic lights in a mobile view', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpBar(tester, width: 420);
|
||||
|
||||
expect(
|
||||
tester.getRect(find.text(appName)).left,
|
||||
greaterThan(trafficLightsWidth),
|
||||
reason: 'the app name is drawn under the macOS window buttons',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('dragging the app name moves the window', (tester) async {
|
||||
await pumpBar(tester, width: 420);
|
||||
|
||||
await tester.timedDragFrom(
|
||||
tester.getCenter(find.text(appName)),
|
||||
const Offset(0, 40),
|
||||
const Duration(milliseconds: 100),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(events, contains('drag'));
|
||||
});
|
||||
|
||||
testWidgets('the content starts below the bar', (tester) async {
|
||||
await pumpBar(tester, width: 420);
|
||||
|
||||
expect(tester.getTopLeft(find.byKey(_contentKey)).dy, _macOSHeaderHeight);
|
||||
});
|
||||
});
|
||||
|
||||
group('WindowHeaderContainer', () {
|
||||
late ProviderContainer container;
|
||||
|
||||
Future<void> pumpContainer(
|
||||
WidgetTester tester, {
|
||||
required int version,
|
||||
required Size viewSize,
|
||||
}) async {
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
versionProvider.overrideWithBuild((_, _) => version),
|
||||
viewSizeProvider.overrideWithBuild((_, _) => viewSize),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
container.listen(overlayTopOffsetProvider, (_, _) {});
|
||||
globalState.container = container;
|
||||
tester.view.physicalSize = viewSize;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const TestApp(
|
||||
includeNavigatorKey: false,
|
||||
child: WindowHeaderContainer(
|
||||
child: ColoredBox(key: _contentKey, color: Color(0xFF00FF00)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
setUpAll(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_windowChannel, (call) async {
|
||||
if (call.method == 'isMaximized' ||
|
||||
call.method == 'isAlwaysOnTop') {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_windowChannel, null);
|
||||
});
|
||||
|
||||
testWidgets('a narrow desktop window carries the header', (tester) async {
|
||||
await pumpContainer(tester, version: 15, viewSize: const Size(420, 800));
|
||||
|
||||
final shows = _hostShowsHeader(version: 15, isMobileView: true);
|
||||
expect(find.byType(WindowHeader), shows ? findsOneWidget : findsNothing);
|
||||
expect(
|
||||
tester.getTopLeft(find.byKey(_contentKey)).dy,
|
||||
shows ? kHeaderHeight : 0,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a wide window follows the same rule as the offset', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpContainer(tester, version: 15, viewSize: const Size(1200, 800));
|
||||
|
||||
final shows = _hostShowsHeader(version: 15, isMobileView: false);
|
||||
expect(find.byType(WindowHeader), shows ? findsOneWidget : findsNothing);
|
||||
expect(
|
||||
tester.getTopLeft(find.byKey(_contentKey)).dy,
|
||||
shows ? kHeaderHeight : 0,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the overlay offset reserves exactly what the header takes', (
|
||||
tester,
|
||||
) async {
|
||||
for (final viewSize in [const Size(420, 800), const Size(1200, 800)]) {
|
||||
await pumpContainer(tester, version: 15, viewSize: viewSize);
|
||||
|
||||
final shows = _hostShowsHeader(
|
||||
version: 15,
|
||||
isMobileView: viewSize.width <= maxMobileWidth,
|
||||
);
|
||||
expect(
|
||||
container.read(overlayTopOffsetProvider),
|
||||
kToolbarHeight + (shows ? kHeaderHeight : 0),
|
||||
reason: 'view size $viewSize',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -63,6 +63,9 @@ void main() {
|
||||
'height': bounds.height,
|
||||
};
|
||||
}
|
||||
if (call.method == 'isMaximized' || call.method == 'isAlwaysOnTop') {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
});
|
||||
@@ -101,6 +104,17 @@ void main() {
|
||||
expect(_RecordingSystemAction.calls, ['close']);
|
||||
});
|
||||
|
||||
testWidgets('a terminate request is delegated to the system action', (
|
||||
tester,
|
||||
) async {
|
||||
final listener = await pumpWindowManager(tester);
|
||||
|
||||
listener.onWindowShouldTerminate();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_RecordingSystemAction.calls, ['exit']);
|
||||
});
|
||||
|
||||
testWidgets('moving the window records its new position', (tester) async {
|
||||
final listener = await pumpWindowManager(tester);
|
||||
bounds = const Rect.fromLTWH(120, 64, 1000, 800);
|
||||
@@ -176,13 +190,15 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const MaterialApp(
|
||||
home: WindowHeaderContainer(child: Text('body')),
|
||||
child: const TestApp(
|
||||
includeNavigatorKey: false,
|
||||
child: WindowHeaderContainer(child: Text('body')),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('body'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:fl_clash/common/boot_record.dart';
|
||||
import 'package:fl_clash/common/constant.dart';
|
||||
import 'package:fl_clash/plugins/app.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -113,4 +114,40 @@ void main() {
|
||||
|
||||
expect(await App().didCrashOnPreviousExecution(), isFalse);
|
||||
});
|
||||
|
||||
test('reads the last process exit info from Android', () async {
|
||||
MethodCall? receivedCall;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
receivedCall = call;
|
||||
return <String, Object?>{
|
||||
'reason': 10,
|
||||
'timestamp': 1234,
|
||||
'description': 'user requested',
|
||||
};
|
||||
});
|
||||
|
||||
final info = await App().getLastExitInfo();
|
||||
|
||||
expect(receivedCall?.method, 'getLastExitInfo');
|
||||
expect(info?.reason, AppExitReason.userRequested);
|
||||
expect(info?.timestamp, 1234);
|
||||
expect(info?.description, 'user requested');
|
||||
});
|
||||
|
||||
test('uses no exit info when Android cannot report one', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (_) async => null);
|
||||
|
||||
expect(await App().getLastExitInfo(), isNull);
|
||||
});
|
||||
|
||||
test('uses no exit info when the platform call fails', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (_) async {
|
||||
throw PlatformException(code: 'unavailable');
|
||||
});
|
||||
|
||||
expect(await App().getLastExitInfo(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -105,7 +105,6 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
// The trailing slot cross-fades, so both children live for one transition.
|
||||
Future<void> settleTrailing(WidgetTester tester) async {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
@@ -35,6 +35,8 @@ void main() {
|
||||
WidgetTester tester, {
|
||||
List<String> ssids = const [],
|
||||
WifiSsidPermission permission = WifiSsidPermission.denied,
|
||||
bool isAndroid = false,
|
||||
bool isMacOS = false,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1400, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
@@ -55,7 +57,9 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const TestApp(child: OnDemandView()),
|
||||
child: TestApp(
|
||||
child: OnDemandView(isAndroid: isAndroid, isMacOS: isMacOS),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
@@ -128,7 +132,11 @@ void main() {
|
||||
testWidgets('the location prerequisite reflects a denied permission', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpView(tester, permission: WifiSsidPermission.denied);
|
||||
await pumpView(
|
||||
tester,
|
||||
permission: WifiSsidPermission.denied,
|
||||
isMacOS: true,
|
||||
);
|
||||
|
||||
expect(find.bySemanticsLabel('Tap to authorize'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Authorized'), findsNothing);
|
||||
@@ -137,11 +145,34 @@ void main() {
|
||||
testWidgets('the location prerequisite reflects a granted permission', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpView(tester, permission: WifiSsidPermission.granted);
|
||||
await pumpView(
|
||||
tester,
|
||||
permission: WifiSsidPermission.granted,
|
||||
isMacOS: true,
|
||||
);
|
||||
|
||||
expect(find.bySemanticsLabel('Authorized'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Tap to authorize'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Android also asks to be left out of battery optimization', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpView(tester, isAndroid: true);
|
||||
|
||||
expect(find.text('Ignore Battery Optimization'), findsOneWidget);
|
||||
expect(find.text('Location Permission'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a desktop that is not macOS asks for no prerequisite', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpView(tester);
|
||||
|
||||
expect(find.text('Ignore Battery Optimization'), findsNothing);
|
||||
expect(find.text('Location Permission'), findsNothing);
|
||||
expect(find.bySemanticsLabel('Tap to authorize'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
/// The view keys its shared selection notifier by [UniqueKeyStateMixin.key], so
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
#include <tray/tray_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
#include <wifi_ssid/wifi_ssid_plugin_c_api.h>
|
||||
#include <window_ext/window_ext_plugin_c_api.h>
|
||||
#include <window_manager/window_manager_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
@@ -43,8 +42,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
WifiSsidPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("WifiSsidPluginCApi"));
|
||||
WindowExtPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("WindowExtPluginCApi"));
|
||||
WindowManagerPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
tray
|
||||
url_launcher_windows
|
||||
wifi_ssid
|
||||
window_ext
|
||||
window_manager
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user