diff --git a/assets/translations/strings.i18n.json b/assets/translations/strings.i18n.json index a3d5e4ac..6dccf77f 100644 --- a/assets/translations/strings.i18n.json +++ b/assets/translations/strings.i18n.json @@ -78,7 +78,8 @@ "dark": "dark mode", "light": "light mode" }, - "trueBlack": "true black" + "trueBlack": "true black", + "silentStart": "silent start" }, "network": { "sectionTitle": "network", diff --git a/assets/translations/strings_fa.i18n.json b/assets/translations/strings_fa.i18n.json index 2cbf8164..9c1ddae9 100644 --- a/assets/translations/strings_fa.i18n.json +++ b/assets/translations/strings_fa.i18n.json @@ -78,7 +78,8 @@ "dark": "تم تیره", "light": "تم روشن" }, - "trueBlack": "کاملا سیاه" + "trueBlack": "کاملا سیاه", + "silentStart": "اجرای ساکت" }, "network": { "sectionTitle": "شبکه", diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index bed69f1d..f8454c01 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -3,8 +3,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:hiddify/core/app/app.dart'; +import 'package:hiddify/core/prefs/prefs.dart'; import 'package:hiddify/data/data_providers.dart'; import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart'; +import 'package:hiddify/features/common/window/window_controller.dart'; import 'package:hiddify/features/system_tray/system_tray.dart'; import 'package:hiddify/services/deep_link_service.dart'; import 'package:hiddify/services/service_providers.dart'; @@ -13,13 +15,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:loggy/loggy.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:stack_trace/stack_trace.dart' as stack_trace; +import 'package:window_manager/window_manager.dart'; final _loggy = Loggy('bootstrap'); final _stopWatch = Stopwatch(); Future lazyBootstrap(WidgetsBinding widgetsBinding) async { _stopWatch.start(); - FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); // temporary solution: https://github.com/rrousselGit/riverpod/issues/1874 FlutterError.demangleStackTrace = (StackTrace stack) { @@ -28,6 +30,9 @@ Future lazyBootstrap(WidgetsBinding widgetsBinding) async { return stack; }; + FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); + await windowManager.ensureInitialized(); + final sharedPreferences = await SharedPreferences.getInstance(); final container = ProviderContainer( overrides: [sharedPreferencesProvider.overrideWithValue(sharedPreferences)], @@ -35,6 +40,15 @@ Future lazyBootstrap(WidgetsBinding widgetsBinding) async { Loggy.initLoggy(logPrinter: const PrettyPrinter()); + final silentStart = + container.read(prefsControllerProvider).general.silentStart; + if (silentStart) { + FlutterNativeSplash.remove(); + } + if (PlatformUtils.isDesktop) { + await container.read(windowControllerProvider.future); + } + await initAppServices(container.read); await initControllers(container.read); @@ -45,7 +59,7 @@ Future lazyBootstrap(WidgetsBinding widgetsBinding) async { ), ); - FlutterNativeSplash.remove(); + if (!silentStart) FlutterNativeSplash.remove(); _stopWatch.stop(); _loggy.debug("bootstrapping took [${_stopWatch.elapsedMilliseconds}]ms"); } @@ -60,7 +74,6 @@ Future initAppServices( read(clashServiceProvider).init(), read(clashServiceProvider).start(), read(notificationServiceProvider).init(), - if (PlatformUtils.isDesktop) read(windowManagerServiceProvider).init(), ], ); _loggy.debug('initialized app services'); diff --git a/lib/core/app/app_view.dart b/lib/core/app/app_view.dart index 988bb008..cdde1bab 100644 --- a/lib/core/app/app_view.dart +++ b/lib/core/app/app_view.dart @@ -4,6 +4,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:hiddify/core/locale/locale.dart'; import 'package:hiddify/core/router/router.dart'; import 'package:hiddify/core/theme/theme.dart'; +import 'package:hiddify/features/common/common_controllers.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -16,6 +17,8 @@ class AppView extends HookConsumerWidget with PresLogger { final locale = ref.watch(localeControllerProvider).locale; final theme = ref.watch(themeControllerProvider); + ref.watch(commonControllersProvider); + return MaterialApp.router( routerConfig: router, locale: locale, diff --git a/lib/core/prefs/general_prefs.dart b/lib/core/prefs/general_prefs.dart new file mode 100644 index 00000000..8e99b6f2 --- /dev/null +++ b/lib/core/prefs/general_prefs.dart @@ -0,0 +1,17 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'general_prefs.freezed.dart'; +part 'general_prefs.g.dart'; + +@freezed +class GeneralPrefs with _$GeneralPrefs { + const GeneralPrefs._(); + + const factory GeneralPrefs({ + // desktop only + @Default(false) bool silentStart, + }) = _GeneralPrefs; + + factory GeneralPrefs.fromJson(Map json) => + _$GeneralPrefsFromJson(json); +} diff --git a/lib/core/prefs/prefs_controller.dart b/lib/core/prefs/prefs_controller.dart index 48556971..c33bcbe2 100644 --- a/lib/core/prefs/prefs_controller.dart +++ b/lib/core/prefs/prefs_controller.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:hiddify/core/prefs/general_prefs.dart'; import 'package:hiddify/core/prefs/prefs_state.dart'; import 'package:hiddify/data/data_providers.dart'; import 'package:hiddify/domain/clash/clash.dart'; @@ -15,6 +16,7 @@ class PrefsController extends _$PrefsController with AppLogger { @override PrefsState build() { return PrefsState( + general: _getGeneralPrefs(), clash: _getClashPrefs(), network: _getNetworkPrefs(), ); @@ -22,8 +24,15 @@ class PrefsController extends _$PrefsController with AppLogger { SharedPreferences get _prefs => ref.read(sharedPreferencesProvider); + static const _generalKey = "general_prefs"; static const _overridesKey = "clash_overrides"; - static const _networkKey = "clash_overrides"; + static const _networkKey = "network_prefs"; + + GeneralPrefs _getGeneralPrefs() { + final persisted = _prefs.getString(_generalKey); + if (persisted == null) return const GeneralPrefs(); + return GeneralPrefs.fromJson(jsonDecode(persisted) as Map); + } ClashConfig _getClashPrefs() { final persisted = _prefs.getString(_overridesKey); @@ -37,6 +46,14 @@ class PrefsController extends _$PrefsController with AppLogger { return NetworkPrefs.fromJson(jsonDecode(persisted) as Map); } + Future patchGeneralPrefs({bool? silentStart}) async { + final newPrefs = state.general.copyWith( + silentStart: silentStart ?? state.general.silentStart, + ); + await _prefs.setString(_generalKey, jsonEncode(newPrefs.toJson())); + state = state.copyWith(general: newPrefs); + } + Future patchClashOverrides(ClashConfigPatch overrides) async { final newPrefs = state.clash.patch(overrides); await _prefs.setString(_overridesKey, jsonEncode(newPrefs.toJson())); diff --git a/lib/core/prefs/prefs_state.dart b/lib/core/prefs/prefs_state.dart index 23c8d55a..61d10858 100644 --- a/lib/core/prefs/prefs_state.dart +++ b/lib/core/prefs/prefs_state.dart @@ -1,4 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/core/prefs/general_prefs.dart'; import 'package:hiddify/domain/clash/clash.dart'; import 'package:hiddify/domain/connectivity/connectivity.dart'; @@ -9,6 +10,7 @@ class PrefsState with _$PrefsState { const PrefsState._(); const factory PrefsState({ + @Default(GeneralPrefs()) GeneralPrefs general, @Default(ClashConfig()) ClashConfig clash, @Default(NetworkPrefs()) NetworkPrefs network, }) = _PrefsState; diff --git a/lib/features/common/common_controllers.dart b/lib/features/common/common_controllers.dart new file mode 100644 index 00000000..8ef8dd0f --- /dev/null +++ b/lib/features/common/common_controllers.dart @@ -0,0 +1,36 @@ +import 'package:hiddify/features/common/clash/clash_controller.dart'; +import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; +import 'package:hiddify/features/common/window/window_controller.dart'; +import 'package:hiddify/features/system_tray/controller/system_tray_controller.dart'; +import 'package:hiddify/utils/platform_utils.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'common_controllers.g.dart'; + +// this is a temporary solution to keep providers running even when there are no active listeners +// https://github.com/rrousselGit/riverpod/discussions/2730 +@Riverpod(keepAlive: true) +void commonControllers(CommonControllersRef ref) { + ref.listen( + clashControllerProvider, + (previous, next) {}, + fireImmediately: true, + ); + ref.listen( + connectivityControllerProvider, + (previous, next) {}, + fireImmediately: true, + ); + if (PlatformUtils.isDesktop) { + ref.listen( + windowControllerProvider, + (previous, next) {}, + fireImmediately: true, + ); + ref.listen( + systemTrayControllerProvider, + (previous, next) {}, + fireImmediately: true, + ); + } +} diff --git a/lib/features/common/window/window_controller.dart b/lib/features/common/window/window_controller.dart new file mode 100644 index 00000000..2bc65b64 --- /dev/null +++ b/lib/features/common/window/window_controller.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:hiddify/core/prefs/prefs.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:window_manager/window_manager.dart'; + +part 'window_controller.g.dart'; + +// TODO improve +@Riverpod(keepAlive: true) +class WindowController extends _$WindowController + with WindowListener, AppLogger { + @override + Future build() async { + await windowManager.ensureInitialized(); + const windowOptions = WindowOptions( + size: Size(868, 768), + minimumSize: Size(868, 648), + center: true, + ); + await windowManager.setPreventClose(true); + await windowManager.waitUntilReadyToShow( + windowOptions, + () async { + if (ref.read(prefsControllerProvider).general.silentStart) { + loggy.debug("silent start is enabled, hiding window"); + await windowManager.hide(); + } + }, + ); + windowManager.addListener(this); + + ref.onDispose(() { + loggy.debug("disposing"); + windowManager.removeListener(this); + }); + return windowManager.isVisible(); + } + + Future show() async { + await windowManager.show(); + state = const AsyncData(true); + } + + Future hide() async { + await windowManager.close(); + } + + @override + Future onWindowClose() async { + await windowManager.hide(); + state = const AsyncData(false); + } +} diff --git a/lib/features/settings/widgets/general_setting_tiles.dart b/lib/features/settings/widgets/general_setting_tiles.dart index fc7d0585..8e866586 100644 --- a/lib/features/settings/widgets/general_setting_tiles.dart +++ b/lib/features/settings/widgets/general_setting_tiles.dart @@ -3,8 +3,10 @@ import 'package:flutter_localized_locales/flutter_localized_locales.dart'; import 'package:go_router/go_router.dart'; import 'package:hiddify/core/core_providers.dart'; import 'package:hiddify/core/locale/locale.dart'; +import 'package:hiddify/core/prefs/prefs.dart'; import 'package:hiddify/core/theme/theme.dart'; import 'package:hiddify/features/settings/widgets/theme_mode_switch_button.dart'; +import 'package:hiddify/utils/platform_utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:recase/recase.dart'; @@ -15,6 +17,9 @@ class AppearanceSettingTiles extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); + final general = + ref.watch(prefsControllerProvider.select((value) => value.general)); + final locale = ref.watch(localeControllerProvider); final theme = ref.watch(themeControllerProvider); @@ -89,6 +94,16 @@ class AppearanceSettingTiles extends HookConsumerWidget { themeController.change(trueBlack: value); }, ), + if (PlatformUtils.isDesktop) + SwitchListTile( + title: Text(t.settings.general.silentStart.titleCase), + value: general.silentStart, + onChanged: (value) { + ref + .read(prefsControllerProvider.notifier) + .patchGeneralPrefs(silentStart: value); + }, + ), ], ); } diff --git a/lib/features/system_tray/controller/system_tray_controller.dart b/lib/features/system_tray/controller/system_tray_controller.dart index 40557a04..6c86c48b 100644 --- a/lib/features/system_tray/controller/system_tray_controller.dart +++ b/lib/features/system_tray/controller/system_tray_controller.dart @@ -7,48 +7,40 @@ import 'package:hiddify/domain/clash/clash.dart'; import 'package:hiddify/domain/connectivity/connectivity.dart'; import 'package:hiddify/features/common/clash/clash_mode.dart'; import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; +import 'package:hiddify/features/common/window/window_controller.dart'; import 'package:hiddify/gen/assets.gen.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:tray_manager/tray_manager.dart'; -import 'package:window_manager/window_manager.dart'; part 'system_tray_controller.g.dart'; -// TODO: rewrite @Riverpod(keepAlive: true) class SystemTrayController extends _$SystemTrayController with TrayListener, AppLogger { @override Future build() async { - await trayManager.setIcon(Assets.images.logoRound); - trayManager.addListener(this); - ref.onDispose(() { - loggy.debug('disposing'); - trayManager.removeListener(this); - }); - ref.listen( - connectivityControllerProvider, - (_, next) async { - connection = next; - await _updateTray(); - }, - fireImmediately: true, - ); - ref.listen( - clashModeProvider.select((value) => value.valueOrNull), - (_, next) async { - mode = next; - await _updateTray(); - }, - fireImmediately: true, - ); + if (!_initialized) { + loggy.debug('initializing'); + await trayManager.setIcon(Assets.images.logoRound); + trayManager.addListener(this); + _initialized = true; + } + + final connection = ref.watch(connectivityControllerProvider); + final mode = + ref.watch(clashModeProvider.select((value) => value.valueOrNull)); + + loggy.debug('updating system tray'); + await _updateTray(connection, mode); } - late ConnectionStatus connection; - late TunnelMode? mode; + bool _initialized = false; - Future _updateTray() async { + Future _updateTray( + ConnectionStatus connection, + TunnelMode? mode, + ) async { final t = ref.watch(translationsProvider); final trayMenu = Menu( items: [ @@ -85,7 +77,7 @@ class SystemTrayController extends _$SystemTrayController @override Future onTrayIconMouseDown() async { - await windowManager.show(); + await ref.read(windowControllerProvider.notifier).show(); } @override @@ -95,8 +87,8 @@ class SystemTrayController extends _$SystemTrayController } Future handleClickShowApp(MenuItem menuItem) async { - if (await windowManager.isVisible()) return; - await windowManager.show(); + if (await ref.read(windowControllerProvider.future)) return; + await ref.read(windowControllerProvider.notifier).show(); } Future handleClickModeItem( @@ -112,6 +104,7 @@ class SystemTrayController extends _$SystemTrayController return ref.read(connectivityControllerProvider.notifier).toggleConnection(); } + // TODO rewrite Future handleClickExitApp(MenuItem menuItem) async { exit(0); } diff --git a/lib/services/service_providers.dart b/lib/services/service_providers.dart index afad7468..d411e386 100644 --- a/lib/services/service_providers.dart +++ b/lib/services/service_providers.dart @@ -2,7 +2,6 @@ import 'package:hiddify/services/clash/clash.dart'; import 'package:hiddify/services/connectivity/connectivity.dart'; import 'package:hiddify/services/files_editor_service.dart'; import 'package:hiddify/services/notification/notification.dart'; -import 'package:hiddify/services/window_manager_service.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'service_providers.g.dart'; @@ -15,10 +14,6 @@ NotificationService notificationService(NotificationServiceRef ref) => FilesEditorService filesEditorService(FilesEditorServiceRef ref) => FilesEditorService(); -@Riverpod(keepAlive: true) -WindowManagerService windowManagerService(WindowManagerServiceRef ref) => - WindowManagerService(); - @Riverpod(keepAlive: true) ConnectivityService connectivityService(ConnectivityServiceRef ref) => ConnectivityService( diff --git a/lib/services/window_manager_service.dart b/lib/services/window_manager_service.dart deleted file mode 100644 index 01794f1c..00000000 --- a/lib/services/window_manager_service.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:window_manager/window_manager.dart'; - -// TODO: rewrite -class WindowManagerService with WindowListener { - Future init() async { - await windowManager.ensureInitialized(); - const windowOptions = WindowOptions( - size: Size(868, 768), - minimumSize: Size(868, 648), - center: true, - ); - await windowManager.waitUntilReadyToShow(windowOptions); - await windowManager.setPreventClose(true); - windowManager.addListener(this); - } - - @override - Future onWindowClose() async { - await windowManager.hide(); - } - - void dispose() { - windowManager.removeListener(this); - } -}