From 2841c4b6ea1b02d14f89cb64ccb25f56e737ef1a Mon Sep 17 00:00:00 2001 From: problematicconsumer Date: Fri, 1 Sep 2023 15:00:41 +0330 Subject: [PATCH] Add config options --- .../com/hiddify/hiddify/MethodHandler.kt | 9 + .../kotlin/com/hiddify/hiddify/Settings.kt | 4 + .../com/hiddify/hiddify/bg/BoxService.kt | 8 +- .../hiddify/hiddify/constant/SettingsKey.kt | 1 + assets/translations/strings.i18n.json | 86 +++---- assets/translations/strings_fa.i18n.json | 62 ++--- lib/bootstrap.dart | 1 - lib/core/prefs/general_prefs.dart | 2 + lib/core/prefs/misc_prefs.dart | 12 - lib/core/prefs/prefs.dart | 1 - lib/core/router/routes/desktop_routes.dart | 28 +-- lib/core/router/routes/mobile_routes.dart | 30 +-- lib/data/data_providers.dart | 2 + lib/data/repository/config_options_store.dart | 77 +++++++ lib/data/repository/core_facade_impl.dart | 25 +- lib/domain/core_service_failure.dart | 9 + lib/domain/singbox/config_options.dart | 95 ++++++++ lib/domain/singbox/singbox.dart | 1 + lib/domain/singbox/singbox_facade.dart | 5 + lib/features/about/view/about_page.dart | 12 + lib/features/logs/view/logs_page.dart | 2 +- .../settings/view/clash_overrides_page.dart | 100 -------- .../settings/view/config_options_page.dart | 214 ++++++++++++++++++ lib/features/settings/view/settings_page.dart | 61 +---- lib/features/settings/view/view.dart | 2 +- .../widgets/advanced_setting_tiles.dart | 56 +++++ .../widgets/general_setting_tiles.dart | 29 +-- .../widgets/miscellaneous_setting_tiles.dart | 81 ------- .../widgets/network_setting_tiles.dart | 36 --- .../settings/widgets/override_tiles.dart | 153 ------------- .../settings/widgets/sections_widgets.dart | 25 ++ .../widgets/settings_input_dialog.dart | 142 ++++++------ lib/features/settings/widgets/widgets.dart | 5 +- lib/gen/singbox_generated_bindings.dart | 15 ++ lib/services/singbox/ffi_singbox_service.dart | 23 +- .../singbox/mobile_singbox_service.dart | 16 ++ lib/services/singbox/singbox_service.dart | 3 + 37 files changed, 806 insertions(+), 627 deletions(-) delete mode 100644 lib/core/prefs/misc_prefs.dart create mode 100644 lib/data/repository/config_options_store.dart create mode 100644 lib/domain/singbox/config_options.dart delete mode 100644 lib/features/settings/view/clash_overrides_page.dart create mode 100644 lib/features/settings/view/config_options_page.dart create mode 100644 lib/features/settings/widgets/advanced_setting_tiles.dart delete mode 100644 lib/features/settings/widgets/miscellaneous_setting_tiles.dart delete mode 100644 lib/features/settings/widgets/network_setting_tiles.dart delete mode 100644 lib/features/settings/widgets/override_tiles.dart create mode 100644 lib/features/settings/widgets/sections_widgets.dart diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/MethodHandler.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/MethodHandler.kt index 667d1349..09790b4d 100644 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/MethodHandler.kt +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/MethodHandler.kt @@ -19,6 +19,7 @@ class MethodHandler : FlutterPlugin, MethodChannel.MethodCallHandler { enum class Trigger(val method: String) { ParseConfig("parse_config"), SetActiveConfigPath("set_active_config_path"), + ChangeConfigOptions("change_config_options"), Start("start"), Stop("stop"), SelectOutbound("select_outbound"), @@ -60,6 +61,14 @@ class MethodHandler : FlutterPlugin, MethodChannel.MethodCallHandler { result.success(true) } + Trigger.ChangeConfigOptions.method -> { + result.runCatching { + val args = call.arguments as String + Settings.configOptions = args + success(true) + } + } + Trigger.Start.method -> { MainActivity.instance.startService() result.success(true) diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/Settings.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/Settings.kt index e30276bb..bbce7be0 100644 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/Settings.kt +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/Settings.kt @@ -26,6 +26,10 @@ object Settings { get() = preferences.getString(SettingsKey.SELECTED_CONFIG_PATH, "") ?: "" set(value) = preferences.edit().putString(SettingsKey.SELECTED_CONFIG_PATH, value).apply() + var configOptions: String + get() = preferences.getString(SettingsKey.CONFIG_OPTIONS, "") ?: "" + set(value) = preferences.edit().putString(SettingsKey.CONFIG_OPTIONS, value).apply() + var startedByUser: Boolean get() = preferences.getBoolean(SettingsKey.STARTED_BY_USER, false) set(value) = preferences.edit().putBoolean(SettingsKey.STARTED_BY_USER, value).apply() diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/bg/BoxService.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/BoxService.kt index 0ebc5cf0..2702b835 100644 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/bg/BoxService.kt +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/bg/BoxService.kt @@ -132,8 +132,14 @@ class BoxService( return } + val configOptions = Settings.configOptions + if (configOptions.isBlank()) { + stopAndAlert(Alert.EmptyConfiguration) + return + } + val content = try { - Mobile.applyOverrides(selectedConfigPath) + Mobile.buildConfig(selectedConfigPath, configOptions) } catch (e: Exception) { Log.w(TAG, e) stopAndAlert(Alert.EmptyConfiguration) diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/constant/SettingsKey.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/constant/SettingsKey.kt index 1a630d65..7b8eedb1 100644 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/constant/SettingsKey.kt +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/constant/SettingsKey.kt @@ -2,6 +2,7 @@ package com.hiddify.hiddify.constant object SettingsKey { const val SELECTED_CONFIG_PATH = "selected_config_path" + const val CONFIG_OPTIONS = "config_options_json" const val DISABLE_MEMORY_LIMIT = "disable_memory_limit" const val PER_APP_PROXY_ENABLED = "per_app_proxy_enabled" diff --git a/assets/translations/strings.i18n.json b/assets/translations/strings.i18n.json index 16f0fb0a..90f32159 100644 --- a/assets/translations/strings.i18n.json +++ b/assets/translations/strings.i18n.json @@ -89,48 +89,57 @@ "shareAppLogs": "share app logs" }, "settings": { - "pageTitle": "settings", + "pageTitle": "Settings", "general": { - "sectionTitle": "general", - "locale": "language", - "themeMode": "theme mode", + "sectionTitle": "General", + "locale": "Language", + "themeMode": "Theme Mode", "themeModes": { - "system": "follow system theme", - "dark": "dark mode", - "light": "light mode" + "system": "Follow system theme", + "dark": "Dark mode", + "light": "Light mode" }, - "trueBlack": "true black", - "silentStart": "silent start", - "openWorkingDir": "open working directory" + "trueBlack": "True Black", + "silentStart": "Silent Start", + "openWorkingDir": "Open Working Directory" }, - "network": { - "sectionTitle": "network", - "systemProxy": "system proxy", - "systemProxyMsg": "Attach http proxy to VpnService", - "bypassPrivateNetworks": "bypass private networks", - "bypassPrivateNetworksMsg": "Bypass private network addresses" + "advanced": { + "sectionTitle": "Advanced", + "debugMode": "Debug Mode", + "debugModeMsg": "Restart the app for this to take effect" }, - "clash": { - "sectionTitle": "clash proxy overrides", - "doNotModify": "do not modify", - "overrides": { - "httpPort": "HTTP Port", - "socksPort": "Socks Port", - "redirPort": "Redirect Port", - "tproxyPort": "TProxy Port", - "mixedPort": "Mixed Port", - "allowLan": "Allow LAN", - "ipv6": "IPv6", - "mode": "Mode", - "logLevel": "Log Level" - } - }, - "miscellaneous": { - "sectionTitle": "miscellaneous", - "connectionTestUrl": "connection test url", - "concurrentTestCount": "concurrent test count", - "debugMode": "debug mode", - "debugModeMsg": "restart the app for this to take effect" + "config": { + "section": { + "route": "Route Options", + "dns": "DNS Options", + "inbound": "Inbound Options", + "misc": "Misc Options" + }, + "pageTitle": "Config Options", + "executeConfigAsIs": "Execute Config As Is", + "executeConfigAsIsMsg": "Run provided config with minimum changes. only works with sing-box configs", + "logLevel": "Log Level", + "resolveDestination": "Resolve Destination", + "ipv6Mode": "IPv6 Route", + "ipv6Modes": { + "disable": "Disable", + "enable": "Enable", + "prefer": "Prefer", + "only": "Only" + }, + "remoteDnsAddress": "Remote DNS", + "remoteDnsDomainStrategy": "Remote DNS Domain Strategy", + "directDnsAddress": "Direct DNS", + "directDnsDomainStrategy": "Direct DNS Domain Strategy", + "mixedPort": "Mixed Port", + "localDnsPort": "Local DNS Port", + "mtu": "MTU", + "connectionTestUrl": "Connection Test URL", + "urlTestInterval": "URL Test Interval", + "enableClashApi": "Enable Clash API", + "clashApiPort": "Clash API Port", + "enableTun": "Enable TUN", + "setSystemProxy": "Set System Proxy" } }, "about": { @@ -164,6 +173,7 @@ "singbox": { "unexpected": "Unexpected Service Failure", "serviceNotRunning": "Service not Running", + "invalidConfigOptions": "Configuration options are not valid", "invalidConfig": "Configuration is not valid", "create": "Error creating service", "start": "Error starting service" @@ -180,4 +190,4 @@ "invalidConfig": "Invalid Config" } } -} +} \ No newline at end of file diff --git a/assets/translations/strings_fa.i18n.json b/assets/translations/strings_fa.i18n.json index 4d9f3282..9d778ac5 100644 --- a/assets/translations/strings_fa.i18n.json +++ b/assets/translations/strings_fa.i18n.json @@ -103,34 +103,43 @@ "silentStart": "اجرای ساکت", "openWorkingDir": "باز کردن دایرکتوری کاری" }, - "network": { - "sectionTitle": "شبکه", - "systemProxy": "سیستم پراکسی", - "systemProxyMsg": "افزودن سیستم پراکسی به سرویس VPN", - "bypassPrivateNetworks": "عبور دادن شبکه خصوصی", - "bypassPrivateNetworksMsg": "عبور دادن آدرس‌های شبکه خصوصی" - }, - "clash": { - "sectionTitle": "جاگزین‌های پراکسی کلش", - "doNotModify": "تغییر نده", - "overrides": { - "httpPort": "HTTP Port", - "socksPort": "Socks Port", - "redirPort": "Redirect Port", - "tproxyPort": "TProxy Port", - "mixedPort": "Mixed Port", - "allowLan": "Allow LAN", - "ipv6": "IPv6", - "mode": "Mode", - "logLevel": "Log Level" - } - }, - "miscellaneous": { - "sectionTitle": "متفرقه", - "connectionTestUrl": "لینک تست کانکشن", - "concurrentTestCount": "شمار تست همزمان", + "advanced": { + "sectionTitle": "پیشرفته", "debugMode": "دیباگ مود", "debugModeMsg": "برای اعمال این تغییر اپ را ری‌استارت کنید" + }, + "config": { + "section": { + "route": "تنظیمات Route", + "dns": "تنظیمات DNS", + "inbound": "تنظیمات Inbound", + "misc": "تنظیمات متفرقه" + }, + "pageTitle": "تنظیمات کانفیگ", + "executeConfigAsIs": "اجرای کانفیگ بدون تغییر", + "executeConfigAsIsMsg": "کانفیگ را با تغییرات حداقلی اجرا میکند. مناسب برای کانفیگ‌های سینگ‌باکس", + "logLevel": "Log Level", + "resolveDestination": "Resolve Destination", + "ipv6Mode": "IPv6 Route", + "ipv6Modes": { + "disable": "Disable", + "enable": "Enable", + "prefer": "Prefer", + "only": "Only" + }, + "remoteDnsAddress": "Remote DNS", + "remoteDnsDomainStrategy": "Remote DNS Domain Strategy", + "directDnsAddress": "Direct DNS", + "directDnsDomainStrategy": "Direct DNS Domain Strategy", + "mixedPort": "Mixed Port", + "localDnsPort": "Local DNS Port", + "mtu": "MTU", + "connectionTestUrl": "Connection Test URL", + "urlTestInterval": "URL Test Interval", + "enableClashApi": "Enable Clash API", + "clashApiPort": "Clash API Port", + "enableTun": "Enable TUN", + "setSystemProxy": "Set System Proxy" } }, "about": { @@ -164,6 +173,7 @@ "singbox": { "unexpected": "خطایی غیر منتظره در سرویس رخ داد", "serviceNotRunning": "سرویس در حال اجرا نیست", + "invalidConfigOptions": "تنظیمات کانفیگ نامعتبر", "invalidConfig": "کانفیگ غیر معتبر", "create": "در ایجاد سرویس خطایی رخ داده", "start": "در راه‌اندازی سرویس خطایی رخ داده" diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 2ea9c4bf..9f7a3fe6 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -5,7 +5,6 @@ import 'package:flutter/foundation.dart'; 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/misc_prefs.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'; diff --git a/lib/core/prefs/general_prefs.dart b/lib/core/prefs/general_prefs.dart index 50d22986..8115055d 100644 --- a/lib/core/prefs/general_prefs.dart +++ b/lib/core/prefs/general_prefs.dart @@ -1,3 +1,5 @@ import 'package:hiddify/utils/pref_notifier.dart'; final silentStartProvider = PrefNotifier.provider("silent_start", false); + +final debugModeProvider = PrefNotifier.provider("debug_mode", false); diff --git a/lib/core/prefs/misc_prefs.dart b/lib/core/prefs/misc_prefs.dart deleted file mode 100644 index beffa91d..00000000 --- a/lib/core/prefs/misc_prefs.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:hiddify/domain/constants.dart'; -import 'package:hiddify/utils/pref_notifier.dart'; - -final connectionTestUrlProvider = - PrefNotifier.provider("connection_test_url", Defaults.connectionTestUrl); - -final concurrentTestCountProvider = PrefNotifier.provider( - "concurrent_test_count", - Defaults.concurrentTestCount, -); - -final debugModeProvider = PrefNotifier.provider("debug_mode", false); diff --git a/lib/core/prefs/prefs.dart b/lib/core/prefs/prefs.dart index f7e66bef..9cce03db 100644 --- a/lib/core/prefs/prefs.dart +++ b/lib/core/prefs/prefs.dart @@ -1,2 +1 @@ export 'general_prefs.dart'; -export 'misc_prefs.dart'; diff --git a/lib/core/router/routes/desktop_routes.dart b/lib/core/router/routes/desktop_routes.dart index eeeeb3d5..44d03da9 100644 --- a/lib/core/router/routes/desktop_routes.dart +++ b/lib/core/router/routes/desktop_routes.dart @@ -23,9 +23,9 @@ part 'desktop_routes.g.dart'; TypedGoRoute(path: LogsRoute.path), TypedGoRoute( path: SettingsRoute.path, - // routes: [ - // TypedGoRoute(path: ClashOverridesRoute.path), - // ], + routes: [ + TypedGoRoute(path: ConfigOptionsRoute.path), + ], ), TypedGoRoute(path: AboutRoute.path), ], @@ -59,18 +59,18 @@ class SettingsRoute extends GoRouteData { } } -// class ClashOverridesRoute extends GoRouteData { -// const ClashOverridesRoute(); -// static const path = 'clash'; +class ConfigOptionsRoute extends GoRouteData { + const ConfigOptionsRoute(); + static const path = 'config-options'; -// @override -// Page buildPage(BuildContext context, GoRouterState state) { -// return const MaterialPage( -// fullscreenDialog: true, -// child: ClashOverridesPage(), -// ); -// } -// } + @override + Page buildPage(BuildContext context, GoRouterState state) { + return const MaterialPage( + fullscreenDialog: true, + child: ConfigOptionsPage(), + ); + } +} class AboutRoute extends GoRouteData { const AboutRoute(); diff --git a/lib/core/router/routes/mobile_routes.dart b/lib/core/router/routes/mobile_routes.dart index 3d9721cb..c51c506f 100644 --- a/lib/core/router/routes/mobile_routes.dart +++ b/lib/core/router/routes/mobile_routes.dart @@ -20,9 +20,9 @@ part 'mobile_routes.g.dart'; TypedGoRoute(path: LogsRoute.path), TypedGoRoute( path: SettingsRoute.path, - // routes: [ - // TypedGoRoute(path: ClashOverridesRoute.path), - // ], + routes: [ + TypedGoRoute(path: ConfigOptionsRoute.path), + ], ), TypedGoRoute(path: AboutRoute.path), ], @@ -69,20 +69,20 @@ class SettingsRoute extends GoRouteData { } } -// class ClashOverridesRoute extends GoRouteData { -// const ClashOverridesRoute(); -// static const path = 'clash'; +class ConfigOptionsRoute extends GoRouteData { + const ConfigOptionsRoute(); + static const path = 'config-options'; -// static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + static final GlobalKey $parentNavigatorKey = rootNavigatorKey; -// @override -// Page buildPage(BuildContext context, GoRouterState state) { -// return const MaterialPage( -// fullscreenDialog: true, -// child: ClashOverridesPage(), -// ); -// } -// } + @override + Page buildPage(BuildContext context, GoRouterState state) { + return const MaterialPage( + fullscreenDialog: true, + child: ConfigOptionsPage(), + ); + } +} class AboutRoute extends GoRouteData { const AboutRoute(); diff --git a/lib/data/data_providers.dart b/lib/data/data_providers.dart index 94057a07..46e27adc 100644 --- a/lib/data/data_providers.dart +++ b/lib/data/data_providers.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import 'package:hiddify/data/api/clash_api.dart'; import 'package:hiddify/data/local/dao/dao.dart'; import 'package:hiddify/data/local/database.dart'; +import 'package:hiddify/data/repository/config_options_store.dart'; import 'package:hiddify/data/repository/repository.dart'; import 'package:hiddify/data/repository/update_repository_impl.dart'; import 'package:hiddify/domain/app/app.dart'; @@ -58,4 +59,5 @@ CoreFacade coreFacade(CoreFacadeRef ref) => CoreFacadeImpl( ref.watch(filesEditorServiceProvider), ref.watch(clashApiProvider), ref.watch(connectivityServiceProvider), + () => ref.read(configOptionsProvider), ); diff --git a/lib/data/repository/config_options_store.dart b/lib/data/repository/config_options_store.dart new file mode 100644 index 00000000..d6bfca70 --- /dev/null +++ b/lib/data/repository/config_options_store.dart @@ -0,0 +1,77 @@ +import 'package:hiddify/domain/singbox/config_options.dart'; +import 'package:hiddify/utils/pref_notifier.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'config_options_store.g.dart'; + +final _default = ConfigOptions.initial; + +final executeConfigAsIs = + PrefNotifier.provider("execute-config-as-is", _default.executeConfigAsIs); + +final logLevelStore = PrefNotifier.provider( + "log-level", + _default.logLevel, + mapFrom: LogLevel.values.byName, + mapTo: (value) => value.name, +); +final resolveDestinationStore = + PrefNotifier.provider("resolve-destination", _default.resolveDestination); +final ipv6ModeStore = PrefNotifier.provider( + "ipv6-mode", + _default.ipv6Mode, + mapFrom: IPv6Mode.values.byName, + mapTo: (value) => value.name, +); +final remoteDnsAddressStore = + PrefNotifier.provider("remote-dns-address", _default.remoteDnsAddress); +final remoteDnsDomainStrategyStore = PrefNotifier.provider( + "remote-domain-dns-strategy", + _default.remoteDnsDomainStrategy, + mapFrom: DomainStrategy.values.byName, + mapTo: (value) => value.name, +); +final directDnsAddressStore = + PrefNotifier.provider("direct-dns-address", _default.directDnsAddress); +final directDnsDomainStrategyStore = PrefNotifier.provider( + "direct-domain-dns-strategy", + _default.directDnsDomainStrategy, + mapFrom: DomainStrategy.values.byName, + mapTo: (value) => value.name, +); +final mixedPortStore = PrefNotifier.provider("mixed-port", _default.mixedPort); +final localDnsPortStore = + PrefNotifier.provider("localDns-port", _default.localDnsPort); +final mtuStore = PrefNotifier.provider("mtu", _default.mtu); +final connectionTestUrlStore = + PrefNotifier.provider("connection-test-url", _default.connectionTestUrl); +final urlTestIntervalStore = + PrefNotifier.provider("url-test-interval", _default.urlTestInterval); +final enableClashApiStore = + PrefNotifier.provider("enable-clash-api", _default.enableClashApi); +final clashApiPortStore = + PrefNotifier.provider("clash-api-port", _default.clashApiPort); +final enableTunStore = PrefNotifier.provider("enable-tun", _default.enableTun); +final setSystemProxyStore = + PrefNotifier.provider("set-system-proxy", _default.setSystemProxy); + +@riverpod +ConfigOptions configOptions(ConfigOptionsRef ref) => ConfigOptions( + executeConfigAsIs: ref.watch(executeConfigAsIs), + logLevel: ref.watch(logLevelStore), + resolveDestination: ref.watch(resolveDestinationStore), + ipv6Mode: ref.watch(ipv6ModeStore), + remoteDnsAddress: ref.watch(remoteDnsAddressStore), + remoteDnsDomainStrategy: ref.watch(remoteDnsDomainStrategyStore), + directDnsAddress: ref.watch(directDnsAddressStore), + directDnsDomainStrategy: ref.watch(directDnsDomainStrategyStore), + mixedPort: ref.watch(mixedPortStore), + localDnsPort: ref.watch(localDnsPortStore), + mtu: ref.watch(mtuStore), + connectionTestUrl: ref.watch(connectionTestUrlStore), + urlTestInterval: ref.watch(urlTestIntervalStore), + enableClashApi: ref.watch(enableClashApiStore), + clashApiPort: ref.watch(clashApiPortStore), + enableTun: ref.watch(enableTunStore), + setSystemProxy: ref.watch(setSystemProxyStore), + ); diff --git a/lib/data/repository/core_facade_impl.dart b/lib/data/repository/core_facade_impl.dart index c9546c19..9422b96d 100644 --- a/lib/data/repository/core_facade_impl.dart +++ b/lib/data/repository/core_facade_impl.dart @@ -15,12 +15,19 @@ import 'package:hiddify/services/singbox/singbox_service.dart'; import 'package:hiddify/utils/utils.dart'; class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade { - CoreFacadeImpl(this.singbox, this.filesEditor, this.clash, this.connectivity); + CoreFacadeImpl( + this.singbox, + this.filesEditor, + this.clash, + this.connectivity, + this.configOptions, + ); final SingboxService singbox; final FilesEditorService filesEditor; final ClashApi clash; final ConnectivityService connectivity; + final ConfigOptions Function() configOptions; bool _initialized = false; @@ -65,6 +72,21 @@ class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade { ); } + @override + TaskEither changeConfigOptions( + ConfigOptions options, + ) { + return exceptionHandler( + () { + return singbox + .changeConfigOptions(options) + .mapLeft(CoreServiceFailure.invalidConfigOptions) + .run(); + }, + CoreServiceFailure.unexpected, + ); + } + @override TaskEither changeConfig(String fileName) { return exceptionHandler( @@ -72,6 +94,7 @@ class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade { final configPath = filesEditor.configPath(fileName); loggy.debug("changing config to: $configPath"); return setup() + .andThen(() => changeConfigOptions(configOptions())) .andThen( () => singbox.create(configPath).mapLeft(CoreServiceFailure.create), diff --git a/lib/domain/core_service_failure.dart b/lib/domain/core_service_failure.dart index bb0f4fce..adb71910 100644 --- a/lib/domain/core_service_failure.dart +++ b/lib/domain/core_service_failure.dart @@ -16,6 +16,10 @@ sealed class CoreServiceFailure with _$CoreServiceFailure, Failure { const factory CoreServiceFailure.serviceNotRunning([String? message]) = CoreServiceNotRunning; + const factory CoreServiceFailure.invalidConfigOptions([ + String? message, + ]) = InvalidConfigOptions; + const factory CoreServiceFailure.invalidConfig([ String? message, ]) = InvalidConfig; @@ -35,6 +39,7 @@ sealed class CoreServiceFailure with _$CoreServiceFailure, Failure { String? get msg => switch (this) { UnexpectedCoreServiceFailure() => null, CoreServiceNotRunning(:final message) => message, + InvalidConfigOptions(:final message) => message, InvalidConfig(:final message) => message, CoreServiceCreateFailure(:final message) => message, CoreServiceStartFailure(:final message) => message, @@ -52,6 +57,10 @@ sealed class CoreServiceFailure with _$CoreServiceFailure, Failure { type: t.failure.singbox.serviceNotRunning, message: message ), + InvalidConfigOptions(:final message) => ( + type: t.failure.singbox.invalidConfigOptions, + message: message + ), InvalidConfig(:final message) => ( type: t.failure.singbox.invalidConfig, message: message diff --git a/lib/domain/singbox/config_options.dart b/lib/domain/singbox/config_options.dart new file mode 100644 index 00000000..7fdce6c4 --- /dev/null +++ b/lib/domain/singbox/config_options.dart @@ -0,0 +1,95 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hiddify/core/locale/locale.dart'; +import 'package:hiddify/utils/platform_utils.dart'; + +part 'config_options.freezed.dart'; +part 'config_options.g.dart'; + +@freezed +class ConfigOptions with _$ConfigOptions { + @JsonSerializable(fieldRename: FieldRename.kebab) + const factory ConfigOptions({ + @Default(false) bool executeConfigAsIs, + @Default(LogLevel.warn) LogLevel logLevel, + @Default(false) bool resolveDestination, + @Default(IPv6Mode.disable) IPv6Mode ipv6Mode, + @Default("https://8.8.8.8/dns-query") String remoteDnsAddress, + @Default(DomainStrategy.auto) DomainStrategy remoteDnsDomainStrategy, + @Default("https://235.5.5.5/dns-query") String directDnsAddress, + @Default(DomainStrategy.auto) DomainStrategy directDnsDomainStrategy, + @Default(2334) int mixedPort, + @Default(6450) int localDnsPort, + @Default(9000) int mtu, + @Default("https://www.gstatic.com/generate_204") String connectionTestUrl, + @IntervalConverter() + @Default(Duration(minutes: 5)) + Duration urlTestInterval, + @Default(true) bool enableClashApi, + @Default(9090) int clashApiPort, + @Default(false) bool enableTun, + @Default(true) bool setSystemProxy, + }) = _ConfigOptions; + + static ConfigOptions initial = ConfigOptions( + enableTun: !PlatformUtils.isDesktop, + setSystemProxy: PlatformUtils.isDesktop, + ); + + factory ConfigOptions.fromJson(Map json) => + _$ConfigOptionsFromJson(json); +} + +enum LogLevel { + warn, + info, + debug, + trace, +} + +@JsonEnum(valueField: 'key') +enum IPv6Mode { + disable("ipv4_only"), + enable("prefer_ipv4"), + prefer("prefer_ipv6"), + only("ipv6_only"); + + const IPv6Mode(this.key); + + final String key; + + String present(TranslationsEn t) => switch (this) { + disable => t.settings.config.ipv6Modes.disable, + enable => t.settings.config.ipv6Modes.enable, + prefer => t.settings.config.ipv6Modes.prefer, + only => t.settings.config.ipv6Modes.only, + }; +} + +@JsonEnum(valueField: 'key') +enum DomainStrategy { + auto(""), + preferIpv6("prefer_ipv6"), + preferIpv4("prefer_ipv4"), + ipv4Only("ipv4_only"), + ipv6Only("ipv6_only"); + + const DomainStrategy(this.key); + + final String key; + + String get displayName => switch (this) { + auto => "auto", + _ => key, + }; +} + +class IntervalConverter implements JsonConverter { + const IntervalConverter(); + + @override + Duration fromJson(String json) => + Duration(minutes: int.parse(json.replaceAll("m", ""))); + + @override + String toJson(Duration object) => "${object.inMinutes}m"; +} diff --git a/lib/domain/singbox/singbox.dart b/lib/domain/singbox/singbox.dart index 1f8d1d20..1d47ba21 100644 --- a/lib/domain/singbox/singbox.dart +++ b/lib/domain/singbox/singbox.dart @@ -1,3 +1,4 @@ +export 'config_options.dart'; export 'core_status.dart'; export 'outbounds.dart'; export 'proxy_type.dart'; diff --git a/lib/domain/singbox/singbox_facade.dart b/lib/domain/singbox/singbox_facade.dart index 3a8e2e3e..d8714e2e 100644 --- a/lib/domain/singbox/singbox_facade.dart +++ b/lib/domain/singbox/singbox_facade.dart @@ -1,5 +1,6 @@ import 'package:fpdart/fpdart.dart'; import 'package:hiddify/domain/core_service_failure.dart'; +import 'package:hiddify/domain/singbox/config_options.dart'; import 'package:hiddify/domain/singbox/core_status.dart'; import 'package:hiddify/domain/singbox/outbounds.dart'; @@ -8,6 +9,10 @@ abstract interface class SingboxFacade { TaskEither parseConfig(String path); + TaskEither changeConfigOptions( + ConfigOptions options, + ); + TaskEither changeConfig(String fileName); TaskEither start(); diff --git a/lib/features/about/view/about_page.dart b/lib/features/about/view/about_page.dart index 6b9049c0..bb4bd258 100644 --- a/lib/features/about/view/about_page.dart +++ b/lib/features/about/view/about_page.dart @@ -6,6 +6,7 @@ import 'package:hiddify/domain/failures.dart'; import 'package:hiddify/features/common/new_version_dialog.dart'; import 'package:hiddify/features/common/runtime_details.dart'; import 'package:hiddify/gen/assets.gen.dart'; +import 'package:hiddify/services/service_providers.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:recase/recase.dart'; @@ -116,6 +117,17 @@ class AboutPage extends HookConsumerWidget { .checkForUpdates(); }, ), + ListTile( + title: Text(t.settings.general.openWorkingDir), + trailing: const Icon(Icons.arrow_outward_outlined), + onTap: () async { + final path = ref + .read(filesEditorServiceProvider) + .workingDir + .uri; + await UriUtils.tryLaunch(path); + }, + ), ], ), ), diff --git a/lib/features/logs/view/logs_page.dart b/lib/features/logs/view/logs_page.dart index e97a2f07..28b5fd3d 100644 --- a/lib/features/logs/view/logs_page.dart +++ b/lib/features/logs/view/logs_page.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:fpdart/fpdart.dart'; import 'package:gap/gap.dart'; import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/core/prefs/misc_prefs.dart'; +import 'package:hiddify/core/prefs/prefs.dart'; import 'package:hiddify/domain/clash/clash.dart'; import 'package:hiddify/domain/failures.dart'; import 'package:hiddify/features/common/common.dart'; diff --git a/lib/features/settings/view/clash_overrides_page.dart b/lib/features/settings/view/clash_overrides_page.dart deleted file mode 100644 index 0c5ae222..00000000 --- a/lib/features/settings/view/clash_overrides_page.dart +++ /dev/null @@ -1,100 +0,0 @@ -// import 'package:flutter/material.dart'; -// import 'package:hiddify/core/core_providers.dart'; -// import 'package:hiddify/core/prefs/prefs.dart'; -// import 'package:hiddify/domain/clash/clash.dart'; -// import 'package:hiddify/features/settings/widgets/widgets.dart'; -// import 'package:hooks_riverpod/hooks_riverpod.dart'; -// import 'package:recase/recase.dart'; - -// class ClashOverridesPage extends HookConsumerWidget { -// const ClashOverridesPage({super.key}); - -// @override -// Widget build(BuildContext context, WidgetRef ref) { -// final t = ref.watch(translationsProvider); - -// final overrides = -// ref.watch(prefsControllerProvider.select((value) => value.clash)); -// final notifier = ref.watch(prefsControllerProvider.notifier); - -// return Scaffold( -// body: CustomScrollView( -// slivers: [ -// SliverAppBar( -// title: Text(t.settings.clash.sectionTitle.titleCase), -// pinned: true, -// ), -// SliverList.list( -// children: [ -// InputOverrideTile( -// title: t.settings.clash.overrides.httpPort, -// value: overrides.httpPort, -// onChange: (value) => notifier.patchClashOverrides( -// ClashConfigPatch(httpPort: value), -// ), -// ), -// InputOverrideTile( -// title: t.settings.clash.overrides.socksPort, -// value: overrides.socksPort, -// onChange: (value) => notifier.patchClashOverrides( -// ClashConfigPatch(socksPort: value), -// ), -// ), -// InputOverrideTile( -// title: t.settings.clash.overrides.redirPort, -// value: overrides.redirPort, -// onChange: (value) => notifier.patchClashOverrides( -// ClashConfigPatch(redirPort: value), -// ), -// ), -// InputOverrideTile( -// title: t.settings.clash.overrides.tproxyPort, -// value: overrides.tproxyPort, -// onChange: (value) => notifier.patchClashOverrides( -// ClashConfigPatch(tproxyPort: value), -// ), -// ), -// InputOverrideTile( -// title: t.settings.clash.overrides.mixedPort, -// value: overrides.mixedPort, -// onChange: (value) => notifier.patchClashOverrides( -// ClashConfigPatch(mixedPort: value), -// ), -// ), -// ToggleOverrideTile( -// title: t.settings.clash.overrides.allowLan, -// value: overrides.allowLan, -// onChange: (value) => notifier.patchClashOverrides( -// ClashConfigPatch(allowLan: value), -// ), -// ), -// ToggleOverrideTile( -// title: t.settings.clash.overrides.ipv6, -// value: overrides.ipv6, -// onChange: (value) => notifier.patchClashOverrides( -// ClashConfigPatch(ipv6: value), -// ), -// ), -// ChoiceOverrideTile( -// title: t.settings.clash.overrides.mode, -// value: overrides.mode, -// options: TunnelMode.values, -// onChange: (value) => notifier.patchClashOverrides( -// ClashConfigPatch(mode: value), -// ), -// ), -// ChoiceOverrideTile( -// title: t.settings.clash.overrides.logLevel, -// value: overrides.logLevel, -// options: LogLevel.values, -// onChange: (value) => notifier.patchClashOverrides( -// ClashConfigPatch(logLevel: value), -// ), -// ), -// ], -// ), -// ], -// ), -// ); -// } -// } diff --git a/lib/features/settings/view/config_options_page.dart b/lib/features/settings/view/config_options_page.dart new file mode 100644 index 00000000..1d805b55 --- /dev/null +++ b/lib/features/settings/view/config_options_page.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hiddify/core/core_providers.dart'; +import 'package:hiddify/core/prefs/prefs.dart'; +import 'package:hiddify/data/repository/config_options_store.dart'; +import 'package:hiddify/domain/singbox/singbox.dart'; +import 'package:hiddify/features/settings/widgets/widgets.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class ConfigOptionsPage extends HookConsumerWidget { + const ConfigOptionsPage({super.key}); + + static final _default = ConfigOptions.initial; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final t = ref.watch(translationsProvider); + + final options = ref.watch(configOptionsProvider); + + return Scaffold( + appBar: AppBar( + title: Text(t.settings.config.pageTitle), + ), + body: ListView( + children: [ + if (ref.watch(debugModeProvider)) + SwitchListTile( + title: Text(t.settings.config.executeConfigAsIs), + subtitle: Text(t.settings.config.executeConfigAsIsMsg), + value: options.executeConfigAsIs, + onChanged: ref.read(executeConfigAsIs.notifier).update, + ), + ListTile( + title: Text(t.settings.config.logLevel), + subtitle: Text(options.logLevel.name), + onTap: () async { + final logLevel = await SettingsPickerDialog( + title: t.settings.config.logLevel, + selected: options.logLevel, + options: LogLevel.values, + getTitle: (e) => e.name, + resetValue: _default.logLevel, + ).show(context); + if (logLevel == null) return; + await ref.read(logLevelStore.notifier).update(logLevel); + }, + ), + const SettingsDivider(), + SettingsSection(t.settings.config.section.route), + SwitchListTile( + title: Text(t.settings.config.resolveDestination), + value: options.resolveDestination, + onChanged: ref.read(resolveDestinationStore.notifier).update, + ), + ListTile( + title: Text(t.settings.config.ipv6Mode), + subtitle: Text(options.ipv6Mode.present(t)), + onTap: () async { + final ipv6Mode = await SettingsPickerDialog( + title: t.settings.config.ipv6Mode, + selected: options.ipv6Mode, + options: IPv6Mode.values, + getTitle: (e) => e.present(t), + resetValue: _default.ipv6Mode, + ).show(context); + if (ipv6Mode == null) return; + await ref.read(ipv6ModeStore.notifier).update(ipv6Mode); + }, + ), + const SettingsDivider(), + SettingsSection(t.settings.config.section.dns), + ListTile( + title: Text(t.settings.config.remoteDnsAddress), + subtitle: Text(options.remoteDnsAddress), + onTap: () async { + final url = await SettingsInputDialog( + title: t.settings.config.remoteDnsAddress, + initialValue: options.remoteDnsAddress, + resetValue: _default.remoteDnsAddress, + ).show(context); + if (url == null || url.isEmpty) return; + await ref.read(remoteDnsAddressStore.notifier).update(url); + }, + ), + ListTile( + title: Text(t.settings.config.remoteDnsDomainStrategy), + subtitle: Text(options.remoteDnsDomainStrategy.displayName), + onTap: () async { + final domainStrategy = await SettingsPickerDialog( + title: t.settings.config.remoteDnsDomainStrategy, + selected: options.remoteDnsDomainStrategy, + options: DomainStrategy.values, + getTitle: (e) => e.displayName, + resetValue: _default.remoteDnsDomainStrategy, + ).show(context); + if (domainStrategy == null) return; + await ref + .read(remoteDnsDomainStrategyStore.notifier) + .update(domainStrategy); + }, + ), + ListTile( + title: Text(t.settings.config.directDnsAddress), + subtitle: Text(options.directDnsAddress), + onTap: () async { + final url = await SettingsInputDialog( + title: t.settings.config.directDnsAddress, + initialValue: options.directDnsAddress, + resetValue: _default.directDnsAddress, + ).show(context); + if (url == null || url.isEmpty) return; + await ref.read(directDnsAddressStore.notifier).update(url); + }, + ), + ListTile( + title: Text(t.settings.config.directDnsDomainStrategy), + subtitle: Text(options.directDnsDomainStrategy.displayName), + onTap: () async { + final domainStrategy = await SettingsPickerDialog( + title: t.settings.config.directDnsDomainStrategy, + selected: options.directDnsDomainStrategy, + options: DomainStrategy.values, + getTitle: (e) => e.displayName, + resetValue: _default.directDnsDomainStrategy, + ).show(context); + if (domainStrategy == null) return; + await ref + .read(directDnsDomainStrategyStore.notifier) + .update(domainStrategy); + }, + ), + const SettingsDivider(), + SettingsSection(t.settings.config.section.inbound), + SwitchListTile( + title: Text(t.settings.config.enableTun), + value: options.enableTun, + onChanged: ref.read(enableTunStore.notifier).update, + ), + SwitchListTile( + title: Text(t.settings.config.setSystemProxy), + value: options.setSystemProxy, + onChanged: ref.read(setSystemProxyStore.notifier).update, + ), + ListTile( + title: Text(t.settings.config.mixedPort), + subtitle: Text(options.mixedPort.toString()), + onTap: () async { + final mixedPort = await SettingsInputDialog( + title: t.settings.config.mixedPort, + initialValue: options.mixedPort, + resetValue: _default.mixedPort, + validator: isPort, + mapTo: int.tryParse, + digitsOnly: true, + ).show(context); + if (mixedPort == null) return; + await ref.read(mixedPortStore.notifier).update(mixedPort); + }, + ), + ListTile( + title: Text(t.settings.config.localDnsPort), + subtitle: Text(options.localDnsPort.toString()), + onTap: () async { + final localDnsPort = await SettingsInputDialog( + title: t.settings.config.localDnsPort, + initialValue: options.localDnsPort, + resetValue: _default.localDnsPort, + validator: isPort, + mapTo: int.tryParse, + digitsOnly: true, + ).show(context); + if (localDnsPort == null) return; + await ref.read(localDnsPortStore.notifier).update(localDnsPort); + }, + ), + const SettingsDivider(), + SettingsSection(t.settings.config.section.misc), + ListTile( + title: Text(t.settings.config.connectionTestUrl), + subtitle: Text(options.connectionTestUrl), + onTap: () async { + final url = await SettingsInputDialog( + title: t.settings.config.connectionTestUrl, + initialValue: options.connectionTestUrl, + resetValue: _default.connectionTestUrl, + ).show(context); + if (url == null || url.isEmpty || !isUrl(url)) return; + await ref.read(connectionTestUrlStore.notifier).update(url); + }, + ), + ListTile( + title: Text(t.settings.config.clashApiPort), + subtitle: Text(options.clashApiPort.toString()), + onTap: () async { + final clashApiPort = await SettingsInputDialog( + title: t.settings.config.clashApiPort, + initialValue: options.clashApiPort, + resetValue: _default.clashApiPort, + validator: isPort, + mapTo: int.tryParse, + digitsOnly: true, + ).show(context); + if (clashApiPort == null) return; + await ref.read(clashApiPortStore.notifier).update(clashApiPort); + }, + ), + const Gap(24), + ], + ), + ); + } +} diff --git a/lib/features/settings/view/settings_page.dart b/lib/features/settings/view/settings_page.dart index ea995419..a8d39d34 100644 --- a/lib/features/settings/view/settings_page.dart +++ b/lib/features/settings/view/settings_page.dart @@ -1,10 +1,8 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/features/settings/widgets/miscellaneous_setting_tiles.dart'; import 'package:hiddify/features/settings/widgets/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:recase/recase.dart'; class SettingsPage extends HookConsumerWidget { const SettingsPage({super.key}); @@ -13,58 +11,19 @@ class SettingsPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); - // const divider = Divider(indent: 16, endIndent: 16); - return Scaffold( appBar: AppBar( - title: Text(t.settings.pageTitle.titleCase), + title: Text(t.settings.pageTitle), ), - body: ListTileTheme( - data: ListTileTheme.of(context).copyWith( - contentPadding: const EdgeInsetsDirectional.only(start: 48, end: 16), - ), - child: ListView( - children: [ - _SettingsSectionHeader( - t.settings.general.sectionTitle.titleCase, - ), - const AppearanceSettingTiles(), - // divider, - // _SettingsSectionHeader(t.settings.network.sectionTitle.titleCase), - // const NetworkSettingTiles(), - // divider, - // ListTile( - // title: Text(t.settings.clash.sectionTitle.titleCase), - // leading: const Icon(Icons.edit_document), - // contentPadding: const EdgeInsets.symmetric(horizontal: 16), - // onTap: () async { - // await const ClashOverridesRoute().push(context); - // }, - // ), - _SettingsSectionHeader( - t.settings.miscellaneous.sectionTitle.titleCase, - ), - const MiscellaneousSettingTiles(), - const Gap(16), - ], - ), - ), - ); - } -} - -class _SettingsSectionHeader extends StatelessWidget { - const _SettingsSectionHeader(this.title); - - final String title; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Text( - title, - style: Theme.of(context).textTheme.titleMedium, + body: ListView( + children: [ + SettingsSection(t.settings.general.sectionTitle), + const GeneralSettingTiles(), + const SettingsDivider(), + SettingsSection(t.settings.advanced.sectionTitle), + const AdvancedSettingTiles(), + const Gap(16), + ], ), ); } diff --git a/lib/features/settings/view/view.dart b/lib/features/settings/view/view.dart index 8d3cfc42..458e1d4d 100644 --- a/lib/features/settings/view/view.dart +++ b/lib/features/settings/view/view.dart @@ -1,2 +1,2 @@ -export 'clash_overrides_page.dart'; +export 'config_options_page.dart'; export 'settings_page.dart'; diff --git a/lib/features/settings/widgets/advanced_setting_tiles.dart b/lib/features/settings/widgets/advanced_setting_tiles.dart new file mode 100644 index 00000000..3684963f --- /dev/null +++ b/lib/features/settings/widgets/advanced_setting_tiles.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hiddify/core/core_providers.dart'; +import 'package:hiddify/core/prefs/prefs.dart'; +import 'package:hiddify/core/router/routes/routes.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class AdvancedSettingTiles extends HookConsumerWidget { + const AdvancedSettingTiles({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final t = ref.watch(translationsProvider); + + final debug = ref.watch(debugModeProvider); + + return Column( + children: [ + ListTile( + title: Text(t.settings.config.pageTitle), + leading: const Icon(Icons.edit_document), + onTap: () async { + await const ConfigOptionsRoute().push(context); + }, + ), + SwitchListTile( + title: Text(t.settings.advanced.debugMode), + value: debug, + secondary: const Icon(Icons.bug_report), + onChanged: (value) async { + if (value) { + await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(t.settings.advanced.debugMode), + content: Text(t.settings.advanced.debugModeMsg), + actions: [ + TextButton( + onPressed: () => context.pop(true), + child: Text( + MaterialLocalizations.of(context).okButtonLabel, + ), + ), + ], + ); + }, + ); + } + await ref.read(debugModeProvider.notifier).update(value); + }, + ), + ], + ); + } +} diff --git a/lib/features/settings/widgets/general_setting_tiles.dart b/lib/features/settings/widgets/general_setting_tiles.dart index 725a097b..bbde9d4a 100644 --- a/lib/features/settings/widgets/general_setting_tiles.dart +++ b/lib/features/settings/widgets/general_setting_tiles.dart @@ -6,13 +6,11 @@ import 'package:hiddify/core/locale/locale.dart'; import 'package:hiddify/core/prefs/general_prefs.dart'; import 'package:hiddify/core/theme/theme.dart'; import 'package:hiddify/features/settings/widgets/theme_mode_switch_button.dart'; -import 'package:hiddify/services/service_providers.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:recase/recase.dart'; -class AppearanceSettingTiles extends HookConsumerWidget { - const AppearanceSettingTiles({super.key}); +class GeneralSettingTiles extends HookConsumerWidget { + const GeneralSettingTiles({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -26,17 +24,18 @@ class AppearanceSettingTiles extends HookConsumerWidget { return Column( children: [ ListTile( - title: Text(t.settings.general.locale.titleCase), + title: Text(t.settings.general.locale), subtitle: Text( LocaleNamesLocalizationsDelegate.nativeLocaleNames[locale.name] ?? locale.name, ), + leading: const Icon(Icons.language), onTap: () async { final selectedLocale = await showDialog( context: context, builder: (context) { return SimpleDialog( - title: Text(t.settings.general.locale.titleCase), + title: Text(t.settings.general.locale), children: LocalePref.values .map( (e) => RadioListTile( @@ -62,14 +61,13 @@ class AppearanceSettingTiles extends HookConsumerWidget { }, ), ListTile( - title: Text(t.settings.general.themeMode.titleCase), + title: Text(t.settings.general.themeMode), subtitle: Text( switch (theme.themeMode) { ThemeMode.system => t.settings.general.themeModes.system, ThemeMode.light => t.settings.general.themeModes.light, ThemeMode.dark => t.settings.general.themeModes.dark, - } - .sentenceCase, + }, ), trailing: ThemeModeSwitch( themeMode: theme.themeMode, @@ -77,6 +75,7 @@ class AppearanceSettingTiles extends HookConsumerWidget { themeController.change(themeMode: value); }, ), + leading: const Icon(Icons.light_mode), onTap: () async { await themeController.change( themeMode: Theme.of(context).brightness == Brightness.light @@ -86,7 +85,7 @@ class AppearanceSettingTiles extends HookConsumerWidget { }, ), SwitchListTile( - title: Text(t.settings.general.trueBlack.titleCase), + title: Text(t.settings.general.trueBlack), value: theme.trueBlack, onChanged: (value) { themeController.change(trueBlack: value); @@ -94,20 +93,12 @@ class AppearanceSettingTiles extends HookConsumerWidget { ), if (PlatformUtils.isDesktop) ...[ SwitchListTile( - title: Text(t.settings.general.silentStart.titleCase), + title: Text(t.settings.general.silentStart), value: ref.watch(silentStartProvider), onChanged: (value) async { await ref.read(silentStartProvider.notifier).update(value); }, ), - ListTile( - title: Text(t.settings.general.openWorkingDir.titleCase), - trailing: const Icon(Icons.arrow_outward_outlined), - onTap: () async { - final path = ref.read(filesEditorServiceProvider).workingDir.uri; - await UriUtils.tryLaunch(path); - }, - ), ], ], ); diff --git a/lib/features/settings/widgets/miscellaneous_setting_tiles.dart b/lib/features/settings/widgets/miscellaneous_setting_tiles.dart deleted file mode 100644 index 99378659..00000000 --- a/lib/features/settings/widgets/miscellaneous_setting_tiles.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/core/prefs/misc_prefs.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:recase/recase.dart'; - -class MiscellaneousSettingTiles extends HookConsumerWidget { - const MiscellaneousSettingTiles({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final t = ref.watch(translationsProvider); - - // final connectionTestUrl = ref.watch(connectionTestUrlProvider); - // final concurrentTestCount = ref.watch(concurrentTestCountProvider); - final debug = ref.watch(debugModeProvider); - - return Column( - children: [ - // ListTile( - // title: Text(t.settings.miscellaneous.connectionTestUrl.titleCase), - // subtitle: Text(connectionTestUrl), - // onTap: () async { - // final url = await SettingsInputDialog( - // title: t.settings.miscellaneous.connectionTestUrl.titleCase, - // initialValue: connectionTestUrl, - // resetValue: Defaults.connectionTestUrl, - // ).show(context); - // if (url == null || url.isEmpty || !isUrl(url)) return; - // await ref.read(connectionTestUrlProvider.notifier).update(url); - // }, - // ), - // ListTile( - // title: Text(t.settings.miscellaneous.concurrentTestCount.titleCase), - // trailing: Text(concurrentTestCount.toString()), - // leadingAndTrailingTextStyle: Theme.of(context).textTheme.bodyMedium, - // onTap: () async { - // final val = await SettingsInputDialog( - // title: t.settings.miscellaneous.concurrentTestCount.titleCase, - // initialValue: concurrentTestCount, - // resetValue: Defaults.concurrentTestCount, - // mapTo: (value) => int.tryParse(value), - // digitsOnly: true, - // ).show(context); - // if (val == null || val < 1) return; - // await ref.read(concurrentTestCountProvider.notifier).update(val); - // }, - // ), - SwitchListTile( - title: Text(t.settings.miscellaneous.debugMode.titleCase), - value: debug, - onChanged: (value) async { - if (value) { - await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text(t.settings.miscellaneous.debugMode.titleCase), - content: Text( - t.settings.miscellaneous.debugModeMsg.sentenceCase, - ), - actions: [ - TextButton( - onPressed: () => context.pop(true), - child: Text( - MaterialLocalizations.of(context).okButtonLabel, - ), - ), - ], - ); - }, - ); - } - await ref.read(debugModeProvider.notifier).update(value); - }, - ), - ], - ); - } -} diff --git a/lib/features/settings/widgets/network_setting_tiles.dart b/lib/features/settings/widgets/network_setting_tiles.dart deleted file mode 100644 index 7f2c11b4..00000000 --- a/lib/features/settings/widgets/network_setting_tiles.dart +++ /dev/null @@ -1,36 +0,0 @@ -// import 'package:flutter/material.dart'; -// import 'package:hiddify/core/core_providers.dart'; -// import 'package:hiddify/core/prefs/prefs.dart'; -// import 'package:hooks_riverpod/hooks_riverpod.dart'; -// import 'package:recase/recase.dart'; - -// class NetworkSettingTiles extends HookConsumerWidget { -// const NetworkSettingTiles({super.key}); - -// @override -// Widget build(BuildContext context, WidgetRef ref) { -// final t = ref.watch(translationsProvider); - -// final prefs = -// ref.watch(prefsControllerProvider.select((value) => value.network)); -// final notifier = ref.watch(prefsControllerProvider.notifier); - -// return Column( -// children: [ -// SwitchListTile( -// title: Text(t.settings.network.systemProxy.titleCase), -// subtitle: Text(t.settings.network.systemProxyMsg), -// value: prefs.systemProxy, -// onChanged: (value) => notifier.patchNetworkPrefs(systemProxy: value), -// ), -// SwitchListTile( -// title: Text(t.settings.network.bypassPrivateNetworks.titleCase), -// subtitle: Text(t.settings.network.bypassPrivateNetworksMsg), -// value: prefs.bypassPrivateNetworks, -// onChanged: (value) => -// notifier.patchNetworkPrefs(bypassPrivateNetworks: value), -// ), -// ], -// ); -// } -// } diff --git a/lib/features/settings/widgets/override_tiles.dart b/lib/features/settings/widgets/override_tiles.dart deleted file mode 100644 index b0ea84fc..00000000 --- a/lib/features/settings/widgets/override_tiles.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:fpdart/fpdart.dart'; -import 'package:hiddify/core/core_providers.dart'; -import 'package:hiddify/features/settings/widgets/settings_input_dialog.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:recase/recase.dart'; - -class InputOverrideTile extends HookConsumerWidget { - const InputOverrideTile({ - super.key, - required this.title, - required this.value, - this.resetValue, - required this.onChange, - }); - - final String title; - final int? value; - final int? resetValue; - final ValueChanged> onChange; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final t = ref.watch(translationsProvider); - - return ListTile( - title: Text(title), - leadingAndTrailingTextStyle: Theme.of(context).textTheme.bodyMedium, - trailing: Text( - value == null - ? t.settings.clash.doNotModify.sentenceCase - : value.toString(), - ), - onTap: () async { - final result = await OptionalSettingsInputDialog( - title: title, - initialValue: value, - resetValue: optionOf(resetValue), - ).show(context).then( - (value) { - return value?.match?>( - () => none(), - (t) { - final i = int.tryParse(t); - return i == null ? null : some(i); - }, - ); - }, - ); - if (result == null) return; - onChange(result); - }, - ); - } -} - -class ToggleOverrideTile extends HookConsumerWidget { - const ToggleOverrideTile({ - super.key, - required this.title, - required this.value, - required this.onChange, - }); - - final String title; - final bool? value; - final ValueChanged> onChange; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final t = ref.watch(translationsProvider); - - return PopupMenuButton>( - initialValue: optionOf(value), - onSelected: onChange, - child: ListTile( - title: Text(title), - leadingAndTrailingTextStyle: Theme.of(context).textTheme.bodyMedium, - trailing: Text( - (value == null - ? t.settings.clash.doNotModify - : value! - ? t.general.toggle.enabled - : t.general.toggle.disabled) - .sentenceCase, - ), - ), - itemBuilder: (_) { - return [ - PopupMenuItem( - value: none(), - child: Text(t.settings.clash.doNotModify.sentenceCase), - ), - PopupMenuItem( - value: some(true), - child: Text(t.general.toggle.enabled.sentenceCase), - ), - PopupMenuItem( - value: some(false), - child: Text(t.general.toggle.disabled.sentenceCase), - ), - ]; - }, - ); - } -} - -class ChoiceOverrideTile extends HookConsumerWidget { - const ChoiceOverrideTile({ - super.key, - required this.title, - required this.value, - required this.options, - required this.onChange, - }); - - final String title; - final T? value; - final List options; - final ValueChanged> onChange; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final t = ref.watch(translationsProvider); - - return PopupMenuButton>( - initialValue: optionOf(value), - onSelected: onChange, - child: ListTile( - title: Text(title), - leadingAndTrailingTextStyle: Theme.of(context).textTheme.bodyMedium, - trailing: Text( - (value == null ? t.settings.clash.doNotModify : value!.name) - .sentenceCase, - ), - ), - itemBuilder: (_) { - return [ - PopupMenuItem( - value: none(), - child: Text(t.settings.clash.doNotModify.sentenceCase), - ), - ...options.map( - (e) => PopupMenuItem( - value: some(e), - child: Text(e.name.sentenceCase), - ), - ), - ]; - }, - ); - } -} diff --git a/lib/features/settings/widgets/sections_widgets.dart b/lib/features/settings/widgets/sections_widgets.dart new file mode 100644 index 00000000..fc4c28bd --- /dev/null +++ b/lib/features/settings/widgets/sections_widgets.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +class SettingsSection extends StatelessWidget { + const SettingsSection(this.title, {super.key}); + + final String title; + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(title), + titleTextStyle: Theme.of(context).textTheme.titleSmall, + dense: true, + ); + } +} + +class SettingsDivider extends StatelessWidget { + const SettingsDivider({super.key}); + + @override + Widget build(BuildContext context) { + return const Divider(indent: 16, endIndent: 16); + } +} diff --git a/lib/features/settings/widgets/settings_input_dialog.dart b/lib/features/settings/widgets/settings_input_dialog.dart index 51175a80..0fb528ac 100644 --- a/lib/features/settings/widgets/settings_input_dialog.dart +++ b/lib/features/settings/widgets/settings_input_dialog.dart @@ -1,88 +1,18 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:fpdart/fpdart.dart'; +import 'package:go_router/go_router.dart'; import 'package:hiddify/core/core_providers.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -class OptionalSettingsInputDialog extends HookConsumerWidget - with PresLogger { - const OptionalSettingsInputDialog({ - super.key, - required this.title, - this.initialValue, - this.resetValue = const None(), - this.icon, - }); - - final String title; - final T? initialValue; - - /// default value, useful for mandatory fields - final Option resetValue; - final IconData? icon; - - Future?> show(BuildContext context) async { - return showDialog( - context: context, - useRootNavigator: true, - builder: (context) => this, - ); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final t = ref.watch(translationsProvider); - final localizations = MaterialLocalizations.of(context); - - final textController = useTextEditingController( - text: initialValue?.toString(), - ); - - return AlertDialog( - title: Text(title), - icon: icon != null ? Icon(icon) : null, - content: TextFormField( - controller: textController, - inputFormatters: [ - FilteringTextInputFormatter.singleLineFormatter, - ], - autovalidateMode: AutovalidateMode.always, - ), - actions: [ - TextButton( - onPressed: () async { - await Navigator.of(context) - .maybePop(resetValue.map((t) => t.toString())); - }, - child: Text(t.general.reset.toUpperCase()), - ), - TextButton( - onPressed: () async { - await Navigator.of(context).maybePop(); - }, - child: Text(localizations.cancelButtonLabel.toUpperCase()), - ), - TextButton( - onPressed: () async { - // onConfirm(textController.value.text); - await Navigator.of(context) - .maybePop(some(textController.value.text)); - }, - child: Text(localizations.okButtonLabel.toUpperCase()), - ), - ], - ); - } -} - class SettingsInputDialog extends HookConsumerWidget with PresLogger { const SettingsInputDialog({ super.key, required this.title, required this.initialValue, this.mapTo, + this.validator, this.resetValue, this.icon, this.digitsOnly = false, @@ -91,6 +21,7 @@ class SettingsInputDialog extends HookConsumerWidget with PresLogger { final String title; final T initialValue; final T? Function(String value)? mapTo; + final bool Function(String value)? validator; final T? resetValue; final IconData? icon; final bool digitsOnly; @@ -139,7 +70,9 @@ class SettingsInputDialog extends HookConsumerWidget with PresLogger { ), TextButton( onPressed: () async { - if (mapTo != null) { + if (validator?.call(textController.value.text) == false) { + await Navigator.of(context).maybePop(null); + } else if (mapTo != null) { await Navigator.of(context) .maybePop(mapTo!.call(textController.value.text)); } else { @@ -153,3 +86,66 @@ class SettingsInputDialog extends HookConsumerWidget with PresLogger { ); } } + +class SettingsPickerDialog extends HookConsumerWidget with PresLogger { + const SettingsPickerDialog({ + super.key, + required this.title, + required this.selected, + required this.options, + required this.getTitle, + this.resetValue, + }); + + final String title; + final T selected; + final List options; + final String Function(T e) getTitle; + final T? resetValue; + + Future show(BuildContext context) async { + return showDialog( + context: context, + useRootNavigator: true, + builder: (context) => this, + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final t = ref.watch(translationsProvider); + final localizations = MaterialLocalizations.of(context); + + return AlertDialog( + title: Text(title), + content: Column( + children: options + .map( + (e) => RadioListTile( + title: Text(getTitle(e)), + value: e, + groupValue: selected, + onChanged: (value) => context.pop(e), + ), + ) + .toList(), + ), + actions: [ + if (resetValue != null) + TextButton( + onPressed: () async { + await Navigator.of(context).maybePop(resetValue); + }, + child: Text(t.general.reset.toUpperCase()), + ), + TextButton( + onPressed: () async { + await Navigator.of(context).maybePop(); + }, + child: Text(localizations.cancelButtonLabel.toUpperCase()), + ), + ], + scrollable: true, + ); + } +} diff --git a/lib/features/settings/widgets/widgets.dart b/lib/features/settings/widgets/widgets.dart index 9a6bad09..c69f75e5 100644 --- a/lib/features/settings/widgets/widgets.dart +++ b/lib/features/settings/widgets/widgets.dart @@ -1,3 +1,4 @@ +export 'advanced_setting_tiles.dart'; export 'general_setting_tiles.dart'; -export 'network_setting_tiles.dart'; -export 'override_tiles.dart'; +export 'sections_widgets.dart'; +export 'settings_input_dialog.dart'; diff --git a/lib/gen/singbox_generated_bindings.dart b/lib/gen/singbox_generated_bindings.dart index ab8ca402..b83ee1f7 100644 --- a/lib/gen/singbox_generated_bindings.dart +++ b/lib/gen/singbox_generated_bindings.dart @@ -905,6 +905,21 @@ class SingboxNativeLibrary { late final _parse = _parsePtr .asFunction Function(ffi.Pointer)>(); + ffi.Pointer changeConfigOptions( + ffi.Pointer configOptionsJson, + ) { + return _changeConfigOptions( + configOptionsJson, + ); + } + + late final _changeConfigOptionsPtr = _lookup< + ffi.NativeFunction< + ffi.Pointer Function( + ffi.Pointer)>>('changeConfigOptions'); + late final _changeConfigOptions = _changeConfigOptionsPtr + .asFunction Function(ffi.Pointer)>(); + ffi.Pointer create( ffi.Pointer configPath, ) { diff --git a/lib/services/singbox/ffi_singbox_service.dart b/lib/services/singbox/ffi_singbox_service.dart index 39dbe173..1f56e1fe 100644 --- a/lib/services/singbox/ffi_singbox_service.dart +++ b/lib/services/singbox/ffi_singbox_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:ffi'; import 'dart:io'; import 'dart:isolate'; @@ -6,6 +7,7 @@ import 'dart:isolate'; import 'package:combine/combine.dart'; import 'package:ffi/ffi.dart'; import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/domain/singbox/config_options.dart'; import 'package:hiddify/gen/singbox_generated_bindings.dart'; import 'package:hiddify/services/singbox/singbox_service.dart'; import 'package:hiddify/utils/utils.dart'; @@ -77,10 +79,29 @@ class FFISingboxService with InfraLogger implements SingboxService { } @override - TaskEither create(String configPath) { + TaskEither changeConfigOptions(ConfigOptions options) { return TaskEither( () => CombineWorker().execute( () { + final json = jsonEncode(options.toJson()); + final err = _box + .changeConfigOptions(json.toNativeUtf8().cast()) + .cast() + .toDartString(); + if (err.isNotEmpty) { + return left(err); + } + return right(unit); + }, + ), + ); + } + + @override + TaskEither create(String configPath) { + return TaskEither( + () => CombineWorker().execute( + () async { final err = _box .create(configPath.toNativeUtf8().cast()) .cast() diff --git a/lib/services/singbox/mobile_singbox_service.dart b/lib/services/singbox/mobile_singbox_service.dart index d41d5211..4d9b4f22 100644 --- a/lib/services/singbox/mobile_singbox_service.dart +++ b/lib/services/singbox/mobile_singbox_service.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; + import 'package:flutter/services.dart'; import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/domain/singbox/config_options.dart'; import 'package:hiddify/services/singbox/singbox_service.dart'; import 'package:hiddify/utils/utils.dart'; @@ -31,6 +34,19 @@ class MobileSingboxService with InfraLogger implements SingboxService { ); } + @override + TaskEither changeConfigOptions(ConfigOptions options) { + return TaskEither( + () async { + await _methodChannel.invokeMethod( + "change_config_options", + jsonEncode(options.toJson()), + ); + return right(unit); + }, + ); + } + @override TaskEither create(String configPath) { return TaskEither( diff --git a/lib/services/singbox/singbox_service.dart b/lib/services/singbox/singbox_service.dart index f895a17d..3ea8bdd5 100644 --- a/lib/services/singbox/singbox_service.dart +++ b/lib/services/singbox/singbox_service.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/domain/singbox/singbox.dart'; import 'package:hiddify/services/singbox/ffi_singbox_service.dart'; import 'package:hiddify/services/singbox/mobile_singbox_service.dart'; @@ -20,6 +21,8 @@ abstract interface class SingboxService { TaskEither parseConfig(String path); + TaskEither changeConfigOptions(ConfigOptions options); + TaskEither create(String configPath); TaskEither start();