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:
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
|
||||
@@ -47,4 +47,4 @@ class AutoLaunch {
|
||||
}
|
||||
}
|
||||
|
||||
final autoLaunch = system.isDesktop ? AutoLaunch() : null;
|
||||
final autoLaunch = system.isDesktop && !safeModeBuild ? AutoLaunch() : null;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -261,7 +261,6 @@ enum FunctionTag {
|
||||
updateDelay,
|
||||
vpnTip,
|
||||
autoLaunch,
|
||||
renderPause,
|
||||
updatePageIndex,
|
||||
pageChange,
|
||||
proxiesTabChange,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user