feat(platform): move desktop windows to plugins/window and rework the tray

plugins/window replaces the window_manager fork on every desktop.

Changelog: Tray hotkeys, node delays and a delay test
This commit is contained in:
chen08209
2026-09-25 03:56:56 +08:00
parent 2a2522bbc6
commit 1e97716636
126 changed files with 6148 additions and 723 deletions
+1 -1
View File
@@ -9,6 +9,7 @@ export 'converter.dart';
export 'datetime.dart';
export 'dialog.dart';
export 'exception.dart';
export 'feature.dart';
export 'file.dart';
export 'fixed.dart';
export 'function.dart';
@@ -37,7 +38,6 @@ export 'print.dart';
export 'protocol.dart';
export 'provider_reader.dart';
export 'proxy.dart';
export 'render.dart';
export 'request.dart';
export 'scroll.dart';
export 'shape.dart';
+22
View File
@@ -0,0 +1,22 @@
/// Features whose code stays but that ship switched off. A disabled feature
/// hides its setting and ignores the stored value, so switching it back on
/// restores what the user had chosen.
class Feature {
const Feature({
this.sidebarBlur = const bool.fromEnvironment('FEATURE_SIDEBAR_BLUR'),
this.customProviders = const bool.fromEnvironment(
'FEATURE_CUSTOM_PROVIDERS',
),
this.customProxies = const bool.fromEnvironment('FEATURE_CUSTOM_PROXIES'),
});
final bool sidebarBlur;
/// App-level proxy and rule providers, and profiles offered as providers.
final bool customProviders;
/// The proxies a custom overwrite keeps in place of the profile's own.
final bool customProxies;
}
Feature feature = const Feature();
+13
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
void _applyAndReport(dynamic tag, Function func, List<dynamic>? args) {
void report(Object error, StackTrace stackTrace) {
@@ -130,6 +131,18 @@ final debouncer = Debouncer();
final throttler = Throttler();
/// Frames stop while the window is hidden, so a post-frame callback alone
/// would hold tray and hotkey work back until the window is shown again.
void runAfterFrame(VoidCallback callback) {
final binding = SchedulerBinding.instance;
if (!binding.framesEnabled) {
Timer.run(callback);
return;
}
binding.addPostFrameCallback((_) => callback());
binding.scheduleFrame();
}
FutureOr<T> handleWatch<T>({
required Function function,
required void Function() onStart,
+1 -1
View File
@@ -47,4 +47,4 @@ class AutoLaunch {
}
}
final autoLaunch = system.isDesktop ? AutoLaunch() : null;
final autoLaunch = system.isDesktop && !safeModeBuild ? AutoLaunch() : null;
+1 -1
View File
@@ -81,7 +81,7 @@ class Permissions {
}
Future<void> checkLocationPermissions(ProviderReader read) async {
if (!_supportsLocationPermissions()) {
if (!_supportsLocationPermissions() || read(safeModeProvider)) {
return;
}
final res = await WifiSsidManager.instance.checkPermission();
-57
View File
@@ -1,57 +0,0 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:flutter/scheduler.dart';
class Render {
static Render? _instance;
bool _isPaused = false;
final _dispatcher = SchedulerBinding.instance.platformDispatcher;
FrameCallback? _beginFrame;
VoidCallback? _drawFrame;
Render._internal();
factory Render() {
_instance ??= Render._internal();
return _instance!;
}
void active() {
resume();
pause();
}
void pause() {
throttler.call(
FunctionTag.renderPause,
_pause,
duration: const Duration(seconds: 5),
);
}
void resume() {
throttler.cancel(FunctionTag.renderPause);
_resume();
}
void _pause() async {
if (_isPaused) return;
_isPaused = true;
_beginFrame = _dispatcher.onBeginFrame;
_drawFrame = _dispatcher.onDrawFrame;
_dispatcher.onBeginFrame = null;
_dispatcher.onDrawFrame = null;
commonPrint.log('pause');
}
void _resume() {
if (!_isPaused) return;
_isPaused = false;
_dispatcher.onBeginFrame = _beginFrame;
_dispatcher.onDrawFrame = _drawFrame;
_dispatcher.scheduleFrame();
commonPrint.log('resume');
}
}
final Render? render = system.isDesktop ? Render() : null;
+106 -46
View File
@@ -2,13 +2,13 @@ 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:tray/tray.dart';
import 'app_localizations.dart';
import 'l10n_labels.dart';
import 'app_ports.dart';
import 'constant.dart';
import 'keyboard.dart';
import 'provider_reader.dart';
import 'system.dart';
import 'window.dart';
@@ -44,14 +44,22 @@ class AppTray implements TrayPort {
}
String get _trayIconDir {
return isWindows ? 'assets/images/tray/windows' : 'assets/images/tray/unix';
if (isWindows) {
return 'assets/images/tray/windows';
}
return isMacOS ? 'assets/images/tray/macos' : 'assets/images/tray/unix';
}
String getTrayIcon({required bool isStart, required bool tunEnable}) {
final status = switch ((isMacOS || !isStart, tunEnable)) {
(true, _) => 1,
(false, false) => 2,
(false, true) => 3,
String getTrayIcon({
required bool isStart,
required bool tunEnable,
required bool safeMode,
}) {
final status = switch ((safeMode, isMacOS || !isStart, tunEnable)) {
(true, _, _) => 4,
(false, true, _) => 1,
(false, false, false) => 2,
(false, false, true) => 3,
};
return '$_trayIconDir/status_$status.$_trayIconSuffix';
}
@@ -77,10 +85,14 @@ class AppTray implements TrayPort {
getTrayIcon(
isStart: trayState.isStart,
tunEnable: trayState.tunEnable,
safeMode: trayState.safeMode,
),
isTemplate: isMacOS,
size: isMacOS ? 18 : 16,
),
toolTip: appName,
toolTip: trayState.safeMode
? currentAppLocalizations.safeModeAppTitle(appName)
: appName,
menu: _buildMenu(trayState: trayState, read: read),
),
);
@@ -105,19 +117,30 @@ class AppTray implements TrayPort {
final systemAction = read(systemActionProvider.notifier);
final setupAction = read(setupActionProvider.notifier);
final appLocalizations = currentAppLocalizations;
String? shortcut(HotAction action) => _shortcut(trayState, action);
final showItem = TrayMenuAction(
label: appLocalizations.show,
detail: shortcut(HotAction.view),
onSelected: () {
window?.show();
},
);
final exitItem = TrayMenuAction(
label: appLocalizations.exit,
detail: shortcut(HotAction.exit),
onSelected: () {
systemAction.handleExit();
},
);
return [
TrayMenuAction(
label: appLocalizations.show,
onSelected: () {
window?.show();
},
),
showItem,
TrayMenuCheckbox(
label: trayState.isStart
? appLocalizations.stop
: appLocalizations.start,
checked: false,
detail: shortcut(HotAction.start),
onSelected: commonAction.toggleRunning,
),
if (isMacOS)
@@ -131,6 +154,11 @@ class AppTray implements TrayPort {
TrayMenuCheckbox(
label: mode.label,
checked: mode == trayState.mode,
detail: shortcut(switch (mode) {
Mode.rule => HotAction.ruleMode,
Mode.global => HotAction.globalMode,
Mode.direct => HotAction.directMode,
}),
onSelected: () {
setupAction.changeMode(mode);
},
@@ -141,11 +169,13 @@ class AppTray implements TrayPort {
TrayMenuCheckbox(
label: appLocalizations.tun,
checked: trayState.tunEnable,
detail: shortcut(HotAction.tun),
onSelected: systemAction.updateTun,
),
TrayMenuCheckbox(
label: appLocalizations.systemProxy,
checked: trayState.systemProxy,
detail: shortcut(HotAction.proxy),
onSelected: systemAction.updateSystemProxy,
),
const TrayMenuSeparator(),
@@ -157,20 +187,33 @@ class AppTray implements TrayPort {
),
TrayMenuAction(
label: appLocalizations.copyEnvVar,
onSelected: () {
_copyEnv(trayState.port);
},
detail: shortcut(HotAction.copyEnv),
onSelected: systemAction.copyProxyEnv,
),
const TrayMenuSeparator(),
TrayMenuAction(
label: appLocalizations.exit,
onSelected: () {
systemAction.handleExit();
},
),
exitItem,
];
}
String? _shortcut(TrayState trayState, HotAction action) {
final hotKey = trayState.hotKeys[action];
final key = hotKey?.key;
if (hotKey == null || key == null) {
return null;
}
return ShortcutLabels(
isMacOS: isMacOS,
isWindows: isWindows,
).text(hotKey.modifiers, key);
}
String? _delayText(int? delay) {
if (delay == null) {
return null;
}
return delay > 0 ? '$delay' : currentAppLocalizations.timeout;
}
List<TrayMenuItem> _buildGroupMenu({
required TrayState trayState,
required ProviderReader read,
@@ -178,36 +221,53 @@ class AppTray implements TrayPort {
if (trayState.groups.isEmpty) {
return const [];
}
final delays = read(trayDelaysProvider);
final proxiesAction = read(proxiesActionProvider.notifier);
return [
for (final group in trayState.groups)
TrayMenuSubmenu(
label: group.name,
items: [
for (final proxy in group.all)
TrayMenuCheckbox(
label: proxy.name,
checked:
read(selectedProxyNameProvider(group.name)) == proxy.name,
onSelected: () {
read(
proxiesActionProvider.notifier,
).changeProxy(groupName: group.name, proxyName: proxy.name);
},
),
],
_buildGroupSubmenu(
group,
selectedName: read(selectedProxyNameProvider(group.name)),
delays: delays[group.name] ?? const {},
onSelected: (proxyName) {
proxiesAction.changeProxy(
groupName: group.name,
proxyName: proxyName,
);
},
),
TrayMenuAction(
label: HotAction.delayTest.label,
detail: _shortcut(trayState, HotAction.delayTest),
onSelected: () {
proxiesAction.delayTestGroups(trayState.groups);
},
),
const TrayMenuSeparator(),
];
}
Future<void> _copyEnv(int port) async {
final url = 'http://127.0.0.1:$port';
final cmdline = isWindows
? 'set \$env:all_proxy=$url'
: 'export all_proxy=$url';
await Clipboard.setData(ClipboardData(text: cmdline));
TrayMenuSubmenu _buildGroupSubmenu(
Group group, {
required String? selectedName,
required Map<String, int> delays,
required void Function(String proxyName) onSelected,
}) {
return TrayMenuSubmenu(
label: group.name,
detail: _delayText(delays[selectedName]),
items: [
for (final proxy in group.all)
TrayMenuCheckbox(
label: proxy.name,
checked: selectedName == proxy.name,
detail: _delayText(delays[proxy.name]),
onSelected: () {
onSelected(proxy.name);
},
),
],
);
}
}
+75 -47
View File
@@ -6,17 +6,18 @@ import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/config.dart';
import 'package:material_ui/material_ui.dart';
import 'package:screen_retriever/screen_retriever.dart';
import 'package:window_manager/window_manager.dart';
import 'package:window/window.dart';
class Window implements WindowPort {
static Window? _instance;
bool _supportsPosition = false;
WindowEffect? _blurEffect;
late final WindowVisibilityController _visibility =
WindowVisibilityController(
showWindow: _showWindow,
hideWindow: _hideWindow,
isWindowVisible: _isWindowVisible,
setSkipTaskbar: (skip) => windowManager.setSkipTaskbar(skip),
setSkipTaskbar: (skip) => desktopWindow.setSkipTaskbar(skip),
dockSettleDuration: system.isMacOS
? const Duration(seconds: 1)
: Duration.zero,
@@ -35,37 +36,28 @@ class Window implements WindowPort {
commonPrint.log('another instance owns the data directory, exiting');
exit(0);
}
if (system.isWindows) {
for (final scheme in protocolSchemes) {
protocol.register(scheme);
if (!safeModeBuild) {
if (system.isWindows) {
for (final scheme in protocolSchemes) {
protocol.register(scheme);
}
}
if (system.isLinux) {
unawaited(protocol.registerLinux(protocolSchemes));
}
}
if (system.isLinux) {
unawaited(protocol.registerLinux(protocolSchemes));
}
await windowManager.ensureInitialized();
await desktopWindow.ensureInitialized();
_supportsPosition = !system.isMacOS;
if (system.isLinux) {
_supportsPosition = await windowManager.isPositionSupported();
_supportsPosition = await desktopWindow.isPositionSupported();
}
final WindowOptions windowOptions = WindowOptions(
size: props.size,
minimumSize: const Size(380, 400),
);
if (!system.isMacOS || version > 10) {
await windowManager.setTitleBarStyle(TitleBarStyle.hidden);
await desktopWindow.setTitleBarStyle(TitleBarStyle.hidden);
}
await windowManager.setMaximizable(true);
// On Linux the compositor only honors positioning after the window is shown;
// elsewhere position it pre-show to avoid a visible jump.
if (!system.isLinux) {
await _windowPosition(props);
}
await windowManager.waitUntilReadyToShow(windowOptions);
if (system.isLinux) {
await _windowPosition(props);
}
await windowManager.setPreventClose(true);
await desktopWindow.setSize(props.size);
await desktopWindow.setMinimumSize(const Size(380, 400));
await _windowPosition(props);
await desktopWindow.setPreventClose(true);
}
Future<void> _windowPosition(WindowProps props) async {
@@ -73,7 +65,7 @@ class Window implements WindowPort {
final left = props.left;
final top = props.top;
if (left == null || top == null) {
await windowManager.setAlignment(Alignment.center);
await desktopWindow.setAlignment(Alignment.center);
} else {
final size = props.size;
final right = left + size.width;
@@ -94,9 +86,9 @@ class Window implements WindowPort {
displayBounds.contains(Offset(right, bottom));
});
if (isPositionValid) {
await windowManager.setPosition(Offset(left, top));
await desktopWindow.setPosition(Offset(left, top));
} else {
await windowManager.setAlignment(Alignment.center);
await desktopWindow.setAlignment(Alignment.center);
}
}
}
@@ -105,15 +97,15 @@ class Window implements WindowPort {
@override
Future<WindowProps?> captureNormalGeometry(WindowProps current) async {
final states = await Future.wait<bool>([
windowManager.isMaximized(),
windowManager.isFullScreen(),
windowManager.isMinimized(),
desktopWindow.isMaximized(),
desktopWindow.isFullScreen(),
desktopWindow.isMinimized(),
]);
if (states.any((state) => state)) {
return null;
}
final bounds = await windowManager.getBounds();
final bounds = await desktopWindow.getBounds();
if (!bounds.width.isFinite ||
!bounds.height.isFinite ||
bounds.width <= 0 ||
@@ -130,19 +122,57 @@ class Window implements WindowPort {
);
}
/// A probe that finds no effect is not cached, so a failure at startup
/// does not pin the toggle to off for the rest of the session.
Future<WindowEffect> _resolveBlurEffect() async {
final cached = _blurEffect;
if (cached != null) {
return cached;
}
final candidates = system.isMacOS
? const [WindowEffect.blur]
: const [WindowEffect.acrylic, WindowEffect.blur];
for (final effect in candidates) {
if (await desktopWindow.isEffectSupported(effect)) {
return _blurEffect = effect;
}
}
return WindowEffect.none;
}
/// Only Windows 10's accent blur uses the tint; it draws nothing without one.
@override
Future<bool> setBlur({
required bool enabled,
required Brightness brightness,
required Color tint,
}) async {
if (system.isLinux) {
return false;
}
final effect = enabled ? await _resolveBlurEffect() : WindowEffect.none;
await desktopWindow.setEffect(
effect,
tint: system.isWindows
? tint.withValues(alpha: kSidebarBlurOpacity)
: null,
brightness: brightness,
);
return effect != WindowEffect.none;
}
/// Every desktop runner leaves the window hidden until [init] reveals it, so
/// a failure before that point would leave the error screen with no window.
Future<void> showInitFailure() async {
try {
await windowManager.ensureInitialized();
if (await windowManager.isVisible()) {
await desktopWindow.ensureInitialized();
if (await desktopWindow.isVisible()) {
return;
}
await windowManager.waitUntilReadyToShow(
const WindowOptions(size: Size(680, 580), center: true),
);
await windowManager.show();
await windowManager.focus();
await desktopWindow.setSize(const Size(680, 580));
await desktopWindow.center();
await desktopWindow.show();
await desktopWindow.focus();
} catch (e) {
commonPrint.log(
'show init failure window failed ${e.toString()}',
@@ -161,25 +191,23 @@ class Window implements WindowPort {
Future<void> toggle() => _visibility.toggle();
Future<void> _showWindow() async {
render?.resume();
await windowManager.show();
await windowManager.focus();
await desktopWindow.show();
await desktopWindow.focus();
}
Future<void> _hideWindow() async {
render?.pause();
await windowManager.hide();
await desktopWindow.hide();
}
Future<bool> _isWindowVisible() async {
final value = await windowManager.isVisible();
final value = await desktopWindow.isVisible();
commonPrint.log('window visible check: $value');
return value;
}
@override
Future<void> close() async {
await windowManager.close();
await desktopWindow.close();
}
@override
-1
View File
@@ -261,7 +261,6 @@ enum FunctionTag {
updateDelay,
vpnTip,
autoLaunch,
renderPause,
updatePageIndex,
pageChange,
proxiesTabChange,
+9 -4
View File
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/core.dart';
import 'package:fl_clash/enum/enum.dart';
@@ -31,7 +29,7 @@ class _AndroidContainerState extends ConsumerState<AndroidManager>
app?.updateExcludeFromRecents(next);
}, fireImmediately: true);
ref.listenManual(loadedLocaleProvider, (prev, next) {
if (prev != null && prev != next) {
if (prev != null && prev != next && !safeModeBuild) {
app?.initShortcuts();
}
});
@@ -49,15 +47,22 @@ class _AndroidContainerState extends ConsumerState<AndroidManager>
app?.onPackagesChanged = _reloadPackages;
}
/// A reload reads every installed app's label, and a round of store updates
/// reports each app on its own.
void _reloadPackages() {
if (ref.read(packagesProvider).isEmpty) {
return;
}
unawaited(ref.read(systemActionProvider.notifier).getPackages());
debouncer.call(FunctionTag.reloadPackages, () async {
if (mounted) {
await ref.read(systemActionProvider.notifier).getPackages();
}
}, duration: const Duration(seconds: 2));
}
@override
void dispose() {
debouncer.cancel(FunctionTag.reloadPackages);
if (app?.onPackagesChanged == _reloadPackages) {
app?.onPackagesChanged = null;
}
+115 -114
View File
@@ -4,11 +4,12 @@ import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/permission.dart';
import 'package:fl_clash/common/system_dns.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/manager/window_manager.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/animated_visibility.dart';
import 'package:fl_clash/widgets/icon.dart';
import 'package:fl_clash/widgets/sidebar.dart';
import 'package:flutter/foundation.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -28,9 +29,11 @@ class _AppStateManagerState extends ConsumerState<AppStateManager>
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
ref.listenManual(checkIpProvider, (prev, next) {
if (prev != next && next.isInit && next.containsDetection) {
ref.read(networkDetectionProvider.notifier).startCheck();
// A desktop build started to the tray mounts while hidden, and a paused
// render never runs a post-frame callback.
scheduleMicrotask(() {
if (mounted) {
_updateVisible(WidgetsBinding.instance.lifecycleState);
}
});
ref.listenManual(configProvider, (prev, next) {
@@ -38,6 +41,11 @@ class _AppStateManagerState extends ConsumerState<AppStateManager>
ref.read(storeActionProvider.notifier).savePreferencesDebounce();
}
});
ref.listenManual(groupsProvider, (prev, next) {
if (prev != next) {
unawaited(precacheTargetIcons(next.map((group) => group.icon)));
}
}, fireImmediately: true);
ref.listenManual(needUpdateGroupsProvider, (prev, next) {
if (prev != next) {
ref.read(proxiesActionProvider.notifier).updateGroupsDebounce();
@@ -45,7 +53,7 @@ class _AppStateManagerState extends ConsumerState<AppStateManager>
});
ref.listenManual(suspendProvider, (prev, next) {
final isStart = ref.read(isStartProvider);
if (prev != next && isStart) {
if (prev != next && isStart && !ref.read(safeModeProvider)) {
debouncer.call(FunctionTag.suspend, () async {
final core = ref.read(coreHandlerProvider);
if (next == true) {
@@ -53,7 +61,6 @@ class _AppStateManagerState extends ConsumerState<AppStateManager>
} else {
await core.startListener();
}
ref.read(checkIpNumProvider.notifier).add();
});
}
});
@@ -71,17 +78,26 @@ class _AppStateManagerState extends ConsumerState<AppStateManager>
super.dispose();
}
void _updateVisible(AppLifecycleState? state) {
ref.read(appVisibleProvider.notifier).value = switch (state) {
null || AppLifecycleState.resumed || AppLifecycleState.inactive => true,
AppLifecycleState.hidden ||
AppLifecycleState.paused ||
AppLifecycleState.detached => false,
};
}
@override
Future<void> didChangeAppLifecycleState(AppLifecycleState state) async {
commonPrint.log('$state');
_updateVisible(state);
if (state == AppLifecycleState.resumed) {
permissions.check(ref.read);
render?.resume();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
ref.read(setupActionProvider.notifier).tryCheckIp();
ref.read(routeTrackerProvider.notifier).markResumed();
});
}
}
@@ -93,86 +109,81 @@ class _AppStateManagerState extends ConsumerState<AppStateManager>
@override
Widget build(BuildContext context) {
return Listener(
onPointerHover: (_) {
render?.resume();
},
child: widget.child,
);
return widget.child;
}
}
class AppEnvManager extends StatelessWidget {
class AppEnvManager extends ConsumerWidget {
final Widget child;
const AppEnvManager({super.key, required this.child});
String? _bannerMessage({required bool safeMode}) {
if (safeMode) {
return 'SAFE MODE';
}
if (!globalState.isPre) {
return null;
}
return kDebugMode ? 'DEBUG' : globalState.appEnv.toUpperCase();
}
@override
Widget build(BuildContext context) {
if (kDebugMode) {
if (globalState.isPre) {
return Banner(
message: 'DEBUG',
location: BannerLocation.topEnd,
child: child,
);
}
Widget build(BuildContext context, WidgetRef ref) {
final message = _bannerMessage(safeMode: ref.watch(safeModeProvider));
if (message == null) {
return child;
}
if (globalState.isPre) {
return Banner(
message: globalState.appEnv.toUpperCase(),
location: BannerLocation.topEnd,
child: child,
);
}
return child;
return Banner(
message: message,
location: BannerLocation.topEnd,
child: child,
);
}
}
class _SidebarRail extends StatelessWidget {
const _SidebarRail({
class _Sidebar extends ConsumerWidget {
const _Sidebar({
required this.items,
required this.currentIndex,
required this.showLabel,
required this.viewMode,
required this.onSelected,
});
final List<NavigationItem> items;
final int currentIndex;
final bool showLabel;
final ViewMode viewMode;
final void Function(int index) onSelected;
@override
Widget build(BuildContext context) {
final labelStyle = context.textTheme.labelLarge!.copyWith(
color: context.colorScheme.onSurface,
Widget build(BuildContext context, WidgetRef ref) {
final canExpand = canExpandSidebar(viewMode);
final sidebarExpanded = ref.watch(
appSettingProvider.select((state) => state.sidebarExpanded),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: NavigationRail(
scrollable: true,
minExtendedWidth: 200,
backgroundColor: Colors.transparent,
selectedLabelTextStyle: labelStyle,
unselectedLabelTextStyle: labelStyle,
destinations: [
for (final item in items)
NavigationRailDestination(
icon: item.icon,
label: Text(item.label.label),
),
],
onDestinationSelected: onSelected,
extended: false,
selectedIndex: currentIndex,
labelType: showLabel
? NavigationRailLabelType.all
: NavigationRailLabelType.none,
),
),
final version = ref.watch(versionProvider);
return NavigationSidebar(
destinations: [
for (final item in items)
SidebarDestination(glyph: item.glyph, label: item.label.label),
],
selectedIndex: currentIndex,
expanded: canExpand && sidebarExpanded,
onSelected: onSelected,
onToggle: canExpand
? () {
ref
.read(appSettingProvider.notifier)
.update(
(state) =>
state.copyWith(sidebarExpanded: !state.sidebarExpanded),
);
}
: null,
windowControls: windowControlsOverSidebar(
isMacOS: system.isMacOS,
version: version,
),
);
}
}
@@ -183,10 +194,15 @@ class AppSidebarContainer extends ConsumerWidget {
const AppSidebarContainer({super.key, required this.child});
Widget _buildBackground({
required BuildContext context,
required Color color,
required Color edge,
required Widget child,
}) {
return Material(color: context.colorScheme.surfaceContainer, child: child);
return Material(
color: color,
shape: BorderDirectional(end: BorderSide(color: edge)),
child: child,
);
}
void _updateSideBarWidth(WidgetRef ref, double contentWidth) {
@@ -199,8 +215,9 @@ class AppSidebarContainer extends ConsumerWidget {
void _handleToPage(WidgetRef ref, PageLabel pageLabel) {
final focusNode = FocusManager.instance.primaryFocus;
final focusContext = focusNode?.context;
final preserveNavigationFocus =
focusNode?.context?.findAncestorWidgetOfExactType<NavigationRail>() !=
focusContext?.findAncestorWidgetOfExactType<NavigationSidebar>() !=
null;
ref.read(currentPageLabelProvider.notifier).toPage(pageLabel);
if (!preserveNavigationFocus || focusNode == null) {
@@ -217,58 +234,37 @@ class AppSidebarContainer extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final navigationState = ref.watch(navigationStateProvider);
final navigationItems = navigationState.navigationItems;
final isMobileView = navigationState.viewMode == ViewMode.mobile;
final viewMode = navigationState.viewMode;
final currentIndex = navigationState.currentIndex;
final showLabel = ref.watch(appSettingProvider).showLabel;
final blur = ref.watch(windowBlurProvider);
final colorScheme = context.colorScheme;
final surfaceContainer = colorScheme.surfaceContainer;
void onSelected(int index) {
_handleToPage(ref, navigationItems[index].label);
}
final hasSidebar = viewMode != ViewMode.mobile;
final isLtr = Directionality.of(context) == TextDirection.ltr;
return Container(
color: context.colorScheme.surfaceContainer,
color: blur ? Colors.transparent : surfaceContainer,
child: Row(
children: [
AnimatedVisibility.sidebar(
visible: !isMobileView,
visible: hasSidebar,
child: _buildBackground(
context: context,
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (system.isMacOS) const SizedBox(height: 22),
const SizedBox(height: 10),
if (!system.isMacOS) ...[
const ClipRect(child: AppIcon()),
const SizedBox(height: 12),
],
Expanded(
child: ScrollConfiguration(
behavior: const HiddenBarScrollBehavior(),
child: _SidebarRail(
items: navigationItems,
currentIndex: currentIndex,
showLabel: showLabel,
onSelected: (index) {
_handleToPage(ref, navigationItems[index].label);
},
),
),
),
const SizedBox(height: 16),
IconButton(
tooltip: context.appLocalizations.toggleLabel,
onPressed: () {
ref
.read(appSettingProvider.notifier)
.update(
(state) =>
state.copyWith(showLabel: !state.showLabel),
);
},
icon: Icon(
Icons.menu,
color: context.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
],
color: blur
? surfaceContainer.withValues(alpha: kSidebarBlurOpacity)
: surfaceContainer,
edge: colorScheme.outlineVariant,
child: MediaQuery.removePadding(
context: context,
removeLeft: !isLtr,
removeRight: isLtr,
child: _Sidebar(
items: navigationItems,
currentIndex: currentIndex,
viewMode: viewMode,
onSelected: onSelected,
),
),
),
@@ -279,7 +275,12 @@ class AppSidebarContainer extends ConsumerWidget {
child: LayoutBuilder(
builder: (_, constraints) {
_updateSideBarWidth(ref, constraints.maxWidth);
return child;
return MediaQuery.removePadding(
context: context,
removeLeft: hasSidebar && isLtr,
removeRight: hasSidebar && !isLtr,
child: child,
);
},
),
),
+3 -1
View File
@@ -64,7 +64,9 @@ class _ConnectivityManagerState extends ConsumerState<ConnectivityManager> {
final requestId = ++_ssidRequestId;
// The SSID costs a blocking platform call and a location permission on
// Android and macOS, and nothing reads it until a network is excluded.
if (!_onWifi || ref.read(excludeSSIDsProvider).isEmpty) {
if (!_onWifi ||
ref.read(safeModeProvider) ||
ref.read(excludeSSIDsProvider).isEmpty) {
_publishSsid(requestId, null);
return;
}
+13 -9
View File
@@ -4,9 +4,11 @@ import 'dart:collection';
import 'package:collection/collection.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:fl_clash/widgets/navigation_dock.dart';
import 'package:fl_clash/widgets/theme.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -471,12 +473,12 @@ class _MessageContent extends StatelessWidget {
Widget build(BuildContext context) {
final actionState = message.actionState;
final level = message.level;
final icon = level.icon;
final glyph = level.glyph;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 20, color: level.iconColor(context)),
if (glyph != null) ...[
GlyphIcon(glyph, size: 20, color: level.iconColor(context)),
const SizedBox(width: 12),
],
Flexible(
@@ -492,12 +494,14 @@ class _MessageContent extends StatelessWidget {
if (actionState != null) ...[
const SizedBox(width: 16),
CommonMinFilledButtonTheme(
child: FilledButton.tonal(
onPressed: () {
onDismiss(message.id);
actionState.action();
},
child: Text(actionState.actionText),
child: ElasticButton(
child: FilledButton.tonal(
onPressed: () {
onDismiss(message.id);
actionState.action();
},
child: Text(actionState.actionText),
),
),
),
],
+35 -3
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/tray.dart';
import 'package:fl_clash/common/window.dart';
@@ -22,6 +23,8 @@ class TrayManager extends ConsumerStatefulWidget {
class _TrayManagerState extends ConsumerState<TrayManager> {
StreamSubscription<TrayEvent>? _subscription;
bool _isUpdating = false;
bool _hasPendingUpdate = false;
@override
void initState() {
@@ -29,15 +32,20 @@ class _TrayManagerState extends ConsumerState<TrayManager> {
_subscription = Tray.instance.events.listen(_handleTrayEvent);
ref.listenManual(trayStateProvider, (prev, next) {
if (prev != next) {
_reportFailure(ref.read(systemActionProvider.notifier).updateTray());
_requestUpdate();
}
});
ref.listenManual(loadedLocaleProvider, (prev, next) {
if (prev != null && prev != next) {
_reportFailure(ref.read(systemActionProvider.notifier).updateTray());
_requestUpdate();
}
});
if (system.isMacOS) {
ref.listenManual(trayDelaysProvider, (prev, next) {
if (!const DeepCollectionEquality().equals(prev, next)) {
_requestUpdate();
}
});
ref.listenManual(trayTitleStateProvider, (prev, next) {
if (prev != next) {
_reportFailure(
@@ -51,6 +59,30 @@ class _TrayManagerState extends ConsumerState<TrayManager> {
}
}
/// A delay test changes the menu per proxy, so updates in flight coalesce.
void _requestUpdate() {
if (_isUpdating) {
_hasPendingUpdate = true;
return;
}
_isUpdating = true;
_reportFailure(_drainUpdates());
}
Future<void> _drainUpdates() async {
try {
do {
_hasPendingUpdate = false;
await ref.read(systemActionProvider.notifier).updateTray();
} while (_hasPendingUpdate && mounted);
} finally {
_isUpdating = false;
if (_hasPendingUpdate && mounted) {
_requestUpdate();
}
}
}
void _reportFailure(Future<void>? operation) {
if (operation == null) {
return;
@@ -72,7 +104,7 @@ class _TrayManagerState extends ConsumerState<TrayManager> {
case TrayMenuRequested():
_reportFailure(Tray.instance.openMenu());
case TrayMenuItemSelected():
render?.active();
break;
}
}
+74 -139
View File
@@ -1,15 +1,15 @@
import 'dart:async';
import 'dart:ui' show ClipOp;
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/launch.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/config.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:window_manager/window_manager.dart';
import 'package:window/window.dart';
const _windowGeometryDelay = Duration(milliseconds: 120);
@@ -26,6 +26,7 @@ class _WindowContainerState extends ConsumerState<WindowManager>
with WindowListener {
Timer? _windowGeometryTimer;
int _windowGeometryRevision = 0;
int _windowBlurRevision = 0;
@override
Widget build(BuildContext context) {
@@ -45,7 +46,38 @@ class _WindowContainerState extends ConsumerState<WindowManager>
});
}
});
windowManager.addListener(this);
ref.listenManual(windowBlurRequestProvider, (prev, next) {
if (prev != next) {
unawaited(_applyWindowBlur(next));
}
}, fireImmediately: true);
desktopWindow.addListener(this);
}
Future<void> _applyWindowBlur(WindowBlurRequest request) async {
final port = windowPort;
if (port == null) {
return;
}
final revision = ++_windowBlurRevision;
bool active;
try {
active = await port.setBlur(
enabled: request.enabled,
brightness: request.brightness,
tint: request.tint,
);
} catch (error) {
commonPrint.log(
'Window blur failed: ${compactError(error)}',
logLevel: LogLevel.warning,
);
active = false;
}
if (!mounted || revision != _windowBlurRevision) {
return;
}
ref.read(windowBlurProvider.notifier).value = active;
}
@override
@@ -58,11 +90,9 @@ class _WindowContainerState extends ConsumerState<WindowManager>
void onWindowFocus() {
super.onWindowFocus();
commonPrint.log('focus');
render?.resume();
}
/// Another launch, or a Dock reopen, asked for the window; showing it from
/// here keeps the render loop running before it becomes visible.
/// Another launch, or a Dock reopen, asked for the window.
@override
void onWindowActivate() {
super.onWindowActivate();
@@ -113,26 +143,8 @@ class _WindowContainerState extends ConsumerState<WindowManager>
}
@override
void onWindowMove() {
super.onWindowMove();
_scheduleWindowGeometryCapture();
}
@override
void onWindowMoved() {
super.onWindowMoved();
_scheduleWindowGeometryCapture();
}
@override
void onWindowResize() {
super.onWindowResize();
_scheduleWindowGeometryCapture();
}
@override
void onWindowResized() {
super.onWindowResized();
void onWindowGeometryChanged() {
super.onWindowGeometryChanged();
_scheduleWindowGeometryCapture();
}
@@ -141,7 +153,7 @@ class _WindowContainerState extends ConsumerState<WindowManager>
_invalidateWindowGeometryCapture();
super.onWindowMaximize();
if (system.isWindows) {
unawaited(windowManager.setWindowCornerPreference(round: false));
unawaited(desktopWindow.setRoundedCorners(false));
}
}
@@ -149,7 +161,7 @@ class _WindowContainerState extends ConsumerState<WindowManager>
void onWindowUnmaximize() {
super.onWindowUnmaximize();
if (system.isWindows) {
unawaited(windowManager.setWindowCornerPreference(round: true));
unawaited(desktopWindow.setRoundedCorners(true));
}
_scheduleWindowGeometryCapture();
}
@@ -171,14 +183,12 @@ class _WindowContainerState extends ConsumerState<WindowManager>
_invalidateWindowGeometryCapture();
ref.read(storeActionProvider.notifier).savePreferencesDebounce();
commonPrint.log('minimize');
render?.pause();
super.onWindowMinimize();
}
@override
void onWindowRestore() {
commonPrint.log('restore');
render?.resume();
super.onWindowRestore();
_scheduleWindowGeometryCapture();
}
@@ -186,7 +196,7 @@ class _WindowContainerState extends ConsumerState<WindowManager>
@override
void dispose() {
_invalidateWindowGeometryCapture();
windowManager.removeListener(this);
desktopWindow.removeListener(this);
super.dispose();
}
}
@@ -294,7 +304,7 @@ class WindowCaptionState {
class WindowCaptionController extends ValueNotifier<WindowCaptionState>
with WindowListener {
WindowCaptionController() : super(const WindowCaptionState()) {
windowManager.addListener(this);
desktopWindow.addListener(this);
unawaited(_syncFromWindow());
}
@@ -302,9 +312,9 @@ class WindowCaptionController extends ValueNotifier<WindowCaptionState>
Future<void> _syncFromWindow() async {
final states = await Future.wait<bool>([
windowManager.isAlwaysOnTop(),
windowManager.isMaximized(),
windowManager.isFullScreen(),
desktopWindow.isAlwaysOnTop(),
desktopWindow.isMaximized(),
desktopWindow.isFullScreen(),
]);
_set(
WindowCaptionState(
@@ -345,25 +355,25 @@ class WindowCaptionController extends ValueNotifier<WindowCaptionState>
}
Future<void> toggleMaximized() async {
if (await windowManager.isFullScreen()) {
await windowManager.setFullScreen(false);
} else if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
if (await desktopWindow.isFullScreen()) {
await desktopWindow.setFullScreen(false);
} else if (await desktopWindow.isMaximized()) {
await desktopWindow.unmaximize();
} else {
await windowManager.maximize();
await desktopWindow.maximize();
}
}
Future<void> togglePin() async {
final isPinned = await windowManager.isAlwaysOnTop();
await windowManager.setAlwaysOnTop(!isPinned);
_set(value.copyWith(isPinned: await windowManager.isAlwaysOnTop()));
final isPinned = await desktopWindow.isAlwaysOnTop();
await desktopWindow.setAlwaysOnTop(!isPinned);
_set(value.copyWith(isPinned: await desktopWindow.isAlwaysOnTop()));
}
@override
void dispose() {
_disposed = true;
windowManager.removeListener(this);
desktopWindow.removeListener(this);
super.dispose();
}
}
@@ -386,9 +396,12 @@ class _WindowHeaderState extends ConsumerState<WindowHeader> {
@override
Widget build(BuildContext context) {
final translucent =
ref.watch(windowBlurProvider) && !ref.watch(isMobileViewProvider);
return WindowHeaderBar(
height: kHeaderHeight,
onDragStart: windowManager.startDragging,
translucent: translucent,
onDragStart: desktopWindow.startDragging,
onDoubleTap: caption.toggleMaximized,
title: system.isMacOS ? const Text(appName) : null,
actions: system.isMacOS
@@ -396,7 +409,7 @@ class _WindowHeaderState extends ConsumerState<WindowHeader> {
: WindowHeaderActions(
state: caption,
onPin: caption.togglePin,
onMinimize: windowManager.minimize,
onMinimize: desktopWindow.minimize,
onMaximize: caption.toggleMaximized,
onClose: () {
ref.read(systemActionProvider.notifier).handleClose();
@@ -412,6 +425,7 @@ class WindowHeaderBar extends StatelessWidget {
required this.height,
required this.onDragStart,
required this.onDoubleTap,
this.translucent = false,
this.title,
this.actions,
});
@@ -419,15 +433,18 @@ class WindowHeaderBar extends StatelessWidget {
final double height;
final VoidCallback onDragStart;
final VoidCallback onDoubleTap;
final bool translucent;
final Widget? title;
final Widget? actions;
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
return Material(
shape: Border(
bottom: BorderSide(color: context.colorScheme.outlineVariant),
),
color: translucent ? Colors.transparent : null,
shape: translucent
? null
: Border(bottom: BorderSide(color: colorScheme.outlineVariant)),
child: SizedBox(
height: height,
child: Stack(
@@ -440,7 +457,11 @@ class WindowHeaderBar extends StatelessWidget {
},
onDoubleTap: onDoubleTap,
child: ColoredBox(
color: context.colorScheme.surfaceContainerHighest,
color: translucent
? colorScheme.surfaceContainer.withValues(
alpha: kSidebarBlurOpacity,
)
: colorScheme.surfaceContainerHighest,
),
),
),
@@ -529,8 +550,9 @@ class WindowHeaderActions extends StatelessWidget {
iconSize: WidgetStatePropertyAll(pinIconSize),
),
onPressed: onPin,
icon: Icon(
state.isPinned ? Icons.push_pin : Icons.push_pin_outlined,
icon: AnimatedGlyph(
glyph: AppGlyphs.pin,
filled: state.isPinned,
),
),
),
@@ -574,90 +596,3 @@ class WindowHeaderActions extends StatelessWidget {
);
}
}
enum CaptionGlyph { minimize, maximize, restore, close }
/// Painted rather than taken from an icon font so every caption button keeps
/// the same one pixel stroke weight on every platform.
class CaptionIcon extends StatelessWidget {
const CaptionIcon(this.glyph, {super.key});
final CaptionGlyph glyph;
@override
Widget build(BuildContext context) {
final color = IconTheme.of(context).color ?? context.colorScheme.onSurface;
return CustomPaint(
size: const Size.square(captionGlyphSize),
painter: _CaptionGlyphPainter(glyph: glyph, color: color),
);
}
}
class _CaptionGlyphPainter extends CustomPainter {
const _CaptionGlyphPainter({required this.glyph, required this.color});
final CaptionGlyph glyph;
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 1
..strokeJoin = StrokeJoin.round;
const corner = Radius.circular(1);
// Every coordinate sits on a half pixel so a one pixel stroke covers a
// single device pixel at 100% scaling.
final box = (Offset.zero & size).deflate(0.5);
switch (glyph) {
case CaptionGlyph.minimize:
final y = (size.height / 2).floorToDouble() + 0.5;
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
case CaptionGlyph.maximize:
canvas.drawRRect(RRect.fromRectAndRadius(box, corner), paint);
case CaptionGlyph.restore:
const offset = 2.0;
final front = Rect.fromLTRB(
box.left,
box.top + offset,
box.right - offset,
box.bottom,
);
final back = front.shift(const Offset(offset, -offset));
canvas.drawRRect(RRect.fromRectAndRadius(front, corner), paint);
canvas.save();
canvas.clipRect(front.inflate(0.5), clipOp: ClipOp.difference);
canvas.drawRRect(RRect.fromRectAndRadius(back, corner), paint);
canvas.restore();
case CaptionGlyph.close:
paint.strokeCap = StrokeCap.round;
canvas.drawLine(box.topLeft, box.bottomRight, paint);
canvas.drawLine(box.topRight, box.bottomLeft, paint);
}
}
@override
bool shouldRepaint(_CaptionGlyphPainter oldDelegate) =>
glyph != oldDelegate.glyph || color != oldDelegate.color;
}
class AppIcon extends StatelessWidget {
const AppIcon({super.key});
@override
Widget build(BuildContext context) {
return Container(
decoration: ShapeDecoration(
color: context.colorScheme.surfaceContainerHighest,
shape: AppShape.md,
),
padding: const EdgeInsets.all(8),
child: Transform.translate(
offset: const Offset(0, -1),
child: Image.asset('assets/images/icon.png', width: 34, height: 34),
),
);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ class ThemeAction extends _$ThemeAction {
void build() {}
void updateBrightness() {
WidgetsBinding.instance.addPostFrameCallback((_) {
runAfterFrame(() {
ref.read(systemBrightnessProvider.notifier).value =
WidgetsBinding.instance.platformDispatcher.platformBrightness;
});
+58 -33
View File
@@ -10,20 +10,9 @@ UpdateParams updateParams(Ref ref) {
);
return ref.watch(
patchClashConfigProvider.select(
(state) => UpdateParams(
tun: state.tun.getRealTun(routeMode),
(state) => state.toUpdateParams(
routeMode: routeMode,
authentication: authentication.credentials,
allowLan: state.allowLan,
findProcessMode: state.findProcessMode,
mode: state.mode,
logLevel: state.logLevel,
ipv6: state.ipv6,
tcpConcurrent: state.tcpConcurrent,
externalController: state.externalController,
unifiedDelay: state.unifiedDelay,
mixedPort: state.mixedPort,
geoAutoUpdate: state.geoAutoUpdate,
geoUpdateInterval: state.geoUpdateInterval,
),
),
);
@@ -52,6 +41,8 @@ TrayState trayState(Ref ref) {
);
final groups = ref.watch(currentGroupsStateProvider).value;
final selectedMap = ref.watch(selectedMapProvider);
final safeMode = ref.watch(safeModeProvider);
final hotKeys = _trayHotKeys(ref);
return TrayState(
mode: clashConfig.mode,
@@ -63,9 +54,58 @@ TrayState trayState(Ref ref) {
groups: groups,
selectedMap: selectedMap,
showTrayTitle: appSetting.showTrayTitle,
safeMode: safeMode,
hotKeys: hotKeys,
);
}
Map<HotAction, HotKeyAction> _trayHotKeys(Ref ref) {
final failures = ref.watch(hotKeyFailuresProvider);
return {
for (final hotKeyAction in ref.watch(hotKeyActionsProvider))
if (isValidHotKey(hotKeyAction.modifiers, hotKeyAction.key) &&
!failures.containsKey(hotKeyAction.action))
hotKeyAction.action: hotKeyAction,
};
}
/// Measured delays of the proxies the tray lists, by group and then proxy
/// name. Resolved like a proxy card, so a nested group shows its selection.
@riverpod
Map<String, Map<String, int>> trayDelays(Ref ref) {
final delayMap = ref.watch(delayDataSourceProvider);
if (delayMap.isEmpty) {
return const {};
}
final groups = ref.watch(currentGroupsStateProvider).value;
final allGroups = ref.watch(groupsProvider);
final selectedMap = ref.watch(selectedMapProvider);
final defaultTestUrl = ref.watch(
appSettingProvider.select((state) => state.testUrl),
);
final delays = <String, Map<String, int>>{};
for (final group in groups) {
final testUrl = group.testUrl.takeFirstValid([defaultTestUrl]);
final groupDelays = <String, int>{};
for (final proxy in group.all) {
final delay = computeProxyDelayState(
proxyName: proxy.name,
testUrl: testUrl,
groups: allGroups,
selectedMap: selectedMap,
delayMap: delayMap,
).delay;
if (delay != 0) {
groupDelays[proxy.name] = delay;
}
}
if (groupDelays.isNotEmpty) {
delays[group.name] = groupDelays;
}
}
return delays;
}
@riverpod
TrayTitleState trayTitleState(Ref ref) {
final showTrayTitle = ref.watch(
@@ -108,29 +148,12 @@ HotKeyAction getHotKeyAction(Ref ref, HotAction hotAction) {
);
}
@riverpod
({bool isInit, int checkIpNum, bool containsDetection}) checkIp(Ref ref) {
final isInit = ref.watch(initProvider);
final checkIpNum = ref.watch(checkIpNumProvider);
final containsDetection = ref.watch(
dashboardStateProvider.select(
(state) =>
state.dashboardWidgets.contains(DashboardWidget.networkDetection),
),
);
return (
isInit: isInit,
checkIpNum: checkIpNum,
containsDetection: containsDetection,
);
}
@riverpod
bool shouldPatchSystemDns(Ref ref) {
final autoSetSystemDns = ref.watch(
networkSettingProvider.select((state) => state.autoSetSystemDns),
);
if (!autoSetSystemDns) {
if (!autoSetSystemDns || ref.watch(safeModeProvider)) {
return false;
}
final isStart = ref.watch(runTimeProvider.select((state) => state != null));
@@ -183,6 +206,7 @@ SharedState sharedState(Ref ref) {
),
);
final vpnSetting = ref.watch(vpnSettingProvider);
final safeMode = ref.watch(safeModeProvider);
final currentProfileName = currentProfile.label;
final selectedMap = currentProfile.selectedMap;
final onlyStatisticsProxy = appSetting.onlyStatisticsProxy;
@@ -200,12 +224,13 @@ SharedState sharedState(Ref ref) {
startTip: currentAppLocalizations.startVpn,
setupParams: SetupParams(selectedMap: selectedMap, testUrl: testUrl),
vpnOptions: VpnOptions(
enable: vpnSetting.enable,
enable: vpnSetting.enable && !safeMode,
stack: stack,
// VpnService.setHttpProxy cannot carry credentials, so an authenticated
// mixed port must not be declared as the system HTTP proxy; traffic
// still flows through TUN.
systemProxy: vpnSetting.systemProxy && !networkSetting.authenticated,
systemProxy:
vpnSetting.systemProxy && !networkSetting.authenticated && !safeMode,
port: port,
ipv6: vpnSetting.ipv6,
dnsHijacking: vpnSetting.dnsHijacking,