Refactor preferences

This commit is contained in:
problematicconsumer
2024-03-02 22:53:14 +03:30
parent 201ea5e88d
commit 2a994dc348
32 changed files with 1104 additions and 1389 deletions

View File

@@ -1,7 +1,6 @@
import 'package:hiddify/core/preferences/preferences_provider.dart';
import 'package:hiddify/features/config_option/data/config_option_repository.dart';
import 'package:hiddify/features/geo_asset/data/geo_asset_data_providers.dart';
import 'package:hiddify/singbox/service/singbox_service_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'config_option_data_providers.g.dart';
@@ -10,19 +9,9 @@ part 'config_option_data_providers.g.dart';
ConfigOptionRepository configOptionRepository(
ConfigOptionRepositoryRef ref,
) {
return ConfigOptionRepositoryImpl(
return ConfigOptionRepository(
preferences: ref.watch(sharedPreferencesProvider).requireValue,
singbox: ref.watch(singboxServiceProvider),
);
}
@Riverpod(keepAlive: true)
SingBoxConfigOptionRepository singBoxConfigOptionRepository(
SingBoxConfigOptionRepositoryRef ref,
) {
return SingBoxConfigOptionRepositoryImpl(
preferences: ref.watch(sharedPreferencesProvider).requireValue,
optionsRepository: ref.watch(configOptionRepositoryProvider),
getConfigOptions: () => ConfigOptions.singboxOptions(ref),
geoAssetRepository: ref.watch(geoAssetRepositoryProvider).requireValue,
geoAssetPathResolver: ref.watch(geoAssetPathResolverProvider),
);

View File

@@ -1,166 +1,402 @@
import 'package:dartx/dartx.dart';
import 'package:fpdart/fpdart.dart';
import 'package:hiddify/core/model/optional_range.dart';
import 'package:hiddify/core/model/region.dart';
import 'package:hiddify/core/utils/exception_handler.dart';
import 'package:hiddify/features/config_option/model/config_option_entity.dart';
import 'package:hiddify/core/utils/json_converters.dart';
import 'package:hiddify/core/utils/preferences_utils.dart';
import 'package:hiddify/features/config_option/model/config_option_failure.dart';
import 'package:hiddify/features/geo_asset/data/geo_asset_path_resolver.dart';
import 'package:hiddify/features/geo_asset/data/geo_asset_repository.dart';
import 'package:hiddify/features/log/model/log_level.dart';
import 'package:hiddify/singbox/model/singbox_config_enum.dart';
import 'package:hiddify/singbox/model/singbox_config_option.dart';
import 'package:hiddify/singbox/model/singbox_rule.dart';
import 'package:hiddify/singbox/service/singbox_service.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:meta/meta.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
abstract interface class ConfigOptionRepository {
Either<ConfigOptionFailure, ConfigOptionEntity> getConfigOption();
TaskEither<ConfigOptionFailure, Unit> updateConfigOption(
ConfigOptionPatch patch,
abstract class ConfigOptions {
static final serviceMode = PreferencesNotifier.create<ServiceMode, String>(
"service-mode",
ServiceMode.defaultMode,
mapFrom: (value) => ServiceMode.choices.firstWhere((e) => e.key == value),
mapTo: (value) => value.key,
);
TaskEither<ConfigOptionFailure, Unit> resetConfigOption();
TaskEither<ConfigOptionFailure, String> generateWarpConfig();
}
abstract interface class SingBoxConfigOptionRepository {
TaskEither<ConfigOptionFailure, SingboxConfigOption>
getFullSingboxConfigOption();
}
static final logLevel = PreferencesNotifier.create<LogLevel, String>(
"log-level",
LogLevel.warn,
mapFrom: LogLevel.values.byName,
mapTo: (value) => value.name,
);
class ConfigOptionRepositoryImpl
with ExceptionHandler, InfraLogger
implements ConfigOptionRepository {
ConfigOptionRepositoryImpl({
required this.preferences,
required this.singbox,
});
static final resolveDestination = PreferencesNotifier.create<bool, bool>(
"resolve-destination",
false,
);
final SharedPreferences preferences;
final SingboxService singbox;
static final ipv6Mode = PreferencesNotifier.create<IPv6Mode, String>(
"ipv6-mode",
IPv6Mode.disable,
mapFrom: (value) => IPv6Mode.values.firstWhere((e) => e.key == value),
mapTo: (value) => value.key,
);
@override
Either<ConfigOptionFailure, ConfigOptionEntity> getConfigOption() {
try {
final map = ConfigOptionEntity.initial().toJson();
for (final key in map.keys) {
final persisted = preferences.get(key);
if (persisted != null) {
final defaultValue = map[key];
if (defaultValue != null &&
persisted.runtimeType != defaultValue.runtimeType) {
loggy.warning(
"error getting preference[$key], expected type: [${defaultValue.runtimeType}] - received value: [$persisted](${persisted.runtimeType})",
);
continue;
}
map[key] = persisted;
}
static final remoteDnsAddress = PreferencesNotifier.create<String, String>(
"remote-dns-address",
"udp://1.1.1.1",
validator: (value) => value.isNotBlank,
);
static final remoteDnsDomainStrategy =
PreferencesNotifier.create<DomainStrategy, String>(
"remote-dns-domain-strategy",
DomainStrategy.auto,
mapFrom: (value) => DomainStrategy.values.firstWhere((e) => e.key == value),
mapTo: (value) => value.key,
);
static final directDnsAddress = PreferencesNotifier.create<String, String>(
"direct-dns-address",
"1.1.1.1",
validator: (value) => value.isNotBlank,
);
static final directDnsDomainStrategy =
PreferencesNotifier.create<DomainStrategy, String>(
"direct-dns-domain-strategy",
DomainStrategy.auto,
mapFrom: (value) => DomainStrategy.values.firstWhere((e) => e.key == value),
mapTo: (value) => value.key,
);
static final mixedPort = PreferencesNotifier.create<int, int>(
"mixed-port",
2334,
validator: (value) => isPort(value.toString()),
);
static final localDnsPort = PreferencesNotifier.create<int, int>(
"local-dns-port",
6450,
validator: (value) => isPort(value.toString()),
);
static final tunImplementation =
PreferencesNotifier.create<TunImplementation, String>(
"tun-implementation",
TunImplementation.mixed,
mapFrom: TunImplementation.values.byName,
mapTo: (value) => value.name,
);
static final mtu = PreferencesNotifier.create<int, int>("mtu", 9000);
static final strictRoute =
PreferencesNotifier.create<bool, bool>("strict-route", true);
static final connectionTestUrl = PreferencesNotifier.create<String, String>(
"connection-test-url",
"http://cp.cloudflare.com/",
validator: (value) => value.isNotBlank && isUrl(value),
);
static final urlTestInterval = PreferencesNotifier.create<Duration, int>(
"url-test-interval",
const Duration(minutes: 10),
mapFrom: const IntervalInSecondsConverter().fromJson,
mapTo: const IntervalInSecondsConverter().toJson,
);
static final enableClashApi = PreferencesNotifier.create<bool, bool>(
"enable-clash-api",
true,
);
static final clashApiPort = PreferencesNotifier.create<int, int>(
"clash-api-port",
6756,
validator: (value) => isPort(value.toString()),
);
static final bypassLan =
PreferencesNotifier.create<bool, bool>("bypass-lan", false);
static final allowConnectionFromLan = PreferencesNotifier.create<bool, bool>(
"allow-connection-from-lan",
false,
);
static final enableFakeDns = PreferencesNotifier.create<bool, bool>(
"enable-fake-dns",
false,
);
static final enableDnsRouting = PreferencesNotifier.create<bool, bool>(
"enable-dns-routing",
true,
);
static final independentDnsCache = PreferencesNotifier.create<bool, bool>(
"independent-dns-cache",
true,
);
static final enableTlsFragment = PreferencesNotifier.create<bool, bool>(
"enable-tls-fragment",
false,
);
static final tlsFragmentSize =
PreferencesNotifier.create<OptionalRange, String>(
"tls-fragment-size",
const OptionalRange(min: 1, max: 500),
mapFrom: OptionalRange.parse,
mapTo: const OptionalRangeJsonConverter().toJson,
);
static final tlsFragmentSleep =
PreferencesNotifier.create<OptionalRange, String>(
"tls-fragment-sleep",
const OptionalRange(min: 0, max: 500),
mapFrom: OptionalRange.parse,
mapTo: const OptionalRangeJsonConverter().toJson,
);
static final enableTlsMixedSniCase = PreferencesNotifier.create<bool, bool>(
"enable-tls-mixed-sni-case",
false,
);
static final enableTlsPadding = PreferencesNotifier.create<bool, bool>(
"enable-tls-padding",
false,
);
static final tlsPaddingSize =
PreferencesNotifier.create<OptionalRange, String>(
"tls-padding-size",
const OptionalRange(min: 1, max: 1500),
mapFrom: OptionalRange.parse,
mapTo: const OptionalRangeJsonConverter().toJson,
);
static final enableMux = PreferencesNotifier.create<bool, bool>(
"enable-mux",
false,
);
static final muxPadding = PreferencesNotifier.create<bool, bool>(
"mux-padding",
false,
);
static final muxMaxStreams = PreferencesNotifier.create<int, int>(
"mux-max-streams",
8,
validator: (value) => value > 0,
);
static final muxProtocol = PreferencesNotifier.create<MuxProtocol, String>(
"mux-protocol",
MuxProtocol.h2mux,
mapFrom: MuxProtocol.values.byName,
mapTo: (value) => value.name,
);
static final enableWarp = PreferencesNotifier.create<bool, bool>(
"enable-warp",
false,
);
static final warpDetourMode =
PreferencesNotifier.create<WarpDetourMode, String>(
"warp-detour-mode",
WarpDetourMode.outbound,
mapFrom: WarpDetourMode.values.byName,
mapTo: (value) => value.name,
);
static final warpLicenseKey = PreferencesNotifier.create<String, String>(
"warp-license-key",
"",
);
static final warpAccountId = PreferencesNotifier.create<String, String>(
"warp-account-id",
"",
);
static final warpAccessToken = PreferencesNotifier.create<String, String>(
"warp-access-token",
"",
);
static final warpCleanIp = PreferencesNotifier.create<String, String>(
"warp-clean-ip",
"auto",
);
static final warpPort = PreferencesNotifier.create<int, int>(
"warp-port",
0,
validator: (value) => isPort(value.toString()),
);
static final warpNoise = PreferencesNotifier.create<OptionalRange, String>(
"warp-noise",
const OptionalRange(min: 5, max: 10),
mapFrom: (value) => OptionalRange.parse(value, allowEmpty: true),
mapTo: const OptionalRangeJsonConverter().toJson,
);
static final warpNoiseDelay =
PreferencesNotifier.create<OptionalRange, String>(
"warp-noise-delay",
const OptionalRange(min: 20, max: 200),
mapFrom: (value) => OptionalRange.parse(value, allowEmpty: true),
mapTo: const OptionalRangeJsonConverter().toJson,
);
static final warpWireguardConfig = PreferencesNotifier.create<String, String>(
"warp-wireguard-config",
"",
);
static final hasExperimentalFeatures = Provider.autoDispose<bool>(
(ref) {
final mode = ref.watch(serviceMode);
if (PlatformUtils.isDesktop && mode == ServiceMode.tun) {
return true;
}
final options = ConfigOptionEntity.fromJson(map);
return right(options);
} catch (error, stackTrace) {
return left(ConfigOptionUnexpectedFailure(error, stackTrace));
}
}
@override
TaskEither<ConfigOptionFailure, Unit> updateConfigOption(
ConfigOptionPatch patch,
) {
return exceptionHandler(
() async {
final map = patch.toJson();
await updateByJson(map);
return right(unit);
},
ConfigOptionUnexpectedFailure.new,
);
}
@override
TaskEither<ConfigOptionFailure, Unit> resetConfigOption() {
return exceptionHandler(
() async {
final map = ConfigOptionEntity.initial().toJson();
await updateByJson(map);
return right(unit);
},
ConfigOptionUnexpectedFailure.new,
);
}
@visibleForTesting
Future<void> updateByJson(
Map<String, dynamic> options,
) async {
final map = ConfigOptionEntity.initial().toJson();
for (final key in map.keys) {
final value = options[key];
if (value != null) {
loggy.debug("updating [$key] to [$value]");
switch (value) {
case bool _:
await preferences.setBool(key, value);
case String _:
await preferences.setString(key, value);
case int _:
await preferences.setInt(key, value);
case double _:
await preferences.setDouble(key, value);
default:
loggy.warning("unexpected type");
}
if (ref.watch(enableTlsFragment) ||
ref.watch(enableTlsMixedSniCase) ||
ref.watch(enableTlsPadding) ||
ref.watch(enableMux) ||
ref.watch(enableWarp)) {
return true;
}
}
}
@override
TaskEither<ConfigOptionFailure, String> generateWarpConfig() {
return exceptionHandler(
() async {
final options = getConfigOption().getOrElse((l) => throw l);
return await singbox
.generateWarpConfig(
licenseKey: options.warpLicenseKey,
previousAccountId: options.warpAccountId,
previousAccessToken: options.warpAccessToken,
)
.mapLeft((l) => ConfigOptionFailure.unexpected(l))
.flatMap(
(warp) => updateConfigOption(
ConfigOptionPatch(
warpAccountId: warp.accountId,
warpAccessToken: warp.accessToken,
warpWireguardConfig: warp.wireguardConfig,
),
).map((_) => warp.log),
)
.run();
},
(error, stackTrace) {
loggy.error(error);
return ConfigOptionUnexpectedFailure(error, stackTrace);
},
return false;
},
);
/// list of all config option preferences
static final preferences = [
serviceMode,
logLevel,
resolveDestination,
ipv6Mode,
remoteDnsAddress,
remoteDnsDomainStrategy,
directDnsAddress,
directDnsDomainStrategy,
mixedPort,
localDnsPort,
tunImplementation,
mtu,
strictRoute,
connectionTestUrl,
urlTestInterval,
clashApiPort,
bypassLan,
allowConnectionFromLan,
enableDnsRouting,
enableTlsFragment,
tlsFragmentSize,
tlsFragmentSleep,
enableTlsMixedSniCase,
enableTlsPadding,
tlsPaddingSize,
enableMux,
muxPadding,
muxMaxStreams,
muxProtocol,
enableWarp,
warpDetourMode,
warpLicenseKey,
warpAccountId,
warpAccessToken,
warpCleanIp,
warpPort,
warpNoise,
warpWireguardConfig,
];
/// singbox options
///
/// **this is partial, don't use it directly**
static SingboxConfigOption singboxOptions(ProviderRef ref) {
final mode = ref.read(serviceMode);
return SingboxConfigOption(
executeConfigAsIs: false,
logLevel: ref.read(logLevel),
resolveDestination: ref.read(resolveDestination),
ipv6Mode: ref.read(ipv6Mode),
remoteDnsAddress: ref.read(remoteDnsAddress),
remoteDnsDomainStrategy: ref.read(remoteDnsDomainStrategy),
directDnsAddress: ref.read(directDnsAddress),
directDnsDomainStrategy: ref.read(directDnsDomainStrategy),
mixedPort: ref.read(mixedPort),
localDnsPort: ref.read(localDnsPort),
tunImplementation: ref.read(tunImplementation),
mtu: ref.read(mtu),
strictRoute: ref.read(strictRoute),
connectionTestUrl: ref.read(connectionTestUrl),
urlTestInterval: ref.read(urlTestInterval),
enableClashApi: ref.read(enableClashApi),
clashApiPort: ref.read(clashApiPort),
enableTun: mode == ServiceMode.tun,
enableTunService: mode == ServiceMode.tunService,
setSystemProxy: mode == ServiceMode.systemProxy,
bypassLan: ref.read(bypassLan),
allowConnectionFromLan: ref.read(allowConnectionFromLan),
enableFakeDns: ref.read(enableFakeDns),
enableDnsRouting: ref.read(enableDnsRouting),
independentDnsCache: ref.read(independentDnsCache),
enableTlsFragment: ref.read(enableTlsFragment),
tlsFragmentSize: ref.read(tlsFragmentSize),
tlsFragmentSleep: ref.read(tlsFragmentSleep),
enableTlsMixedSniCase: ref.read(enableTlsMixedSniCase),
enableTlsPadding: ref.read(enableTlsPadding),
tlsPaddingSize: ref.read(tlsPaddingSize),
enableMux: ref.read(enableMux),
muxPadding: ref.read(muxPadding),
muxMaxStreams: ref.read(muxMaxStreams),
muxProtocol: ref.read(muxProtocol),
warp: SingboxWarpOption(
enable: ref.read(enableWarp),
mode: ref.read(warpDetourMode),
wireguardConfig: ref.read(warpWireguardConfig),
licenseKey: ref.read(warpLicenseKey),
accountId: ref.read(warpAccountId),
accessToken: ref.read(warpAccessToken),
cleanIp: ref.read(warpCleanIp),
cleanPort: ref.read(warpPort),
warpNoise: ref.read(warpNoise),
warpNoiseDelay: ref.read(warpNoiseDelay),
),
geoipPath: "",
geositePath: "",
rules: [],
);
}
}
class SingBoxConfigOptionRepositoryImpl
with ExceptionHandler, InfraLogger
implements SingBoxConfigOptionRepository {
SingBoxConfigOptionRepositoryImpl({
class ConfigOptionRepository with ExceptionHandler, InfraLogger {
ConfigOptionRepository({
required this.preferences,
required this.optionsRepository,
required this.getConfigOptions,
required this.geoAssetRepository,
required this.geoAssetPathResolver,
});
final SharedPreferences preferences;
final ConfigOptionRepository optionsRepository;
final SingboxConfigOption Function() getConfigOptions;
final GeoAssetRepository geoAssetRepository;
final GeoAssetPathResolver geoAssetPathResolver;
@override
TaskEither<ConfigOptionFailure, SingboxConfigOption>
getFullSingboxConfigOption() {
return exceptionHandler(
@@ -204,9 +440,7 @@ class SingBoxConfigOptionRepositoryImpl
.getOrElse((l) => throw l)
.run();
final persisted =
optionsRepository.getConfigOption().getOrElse((l) => throw l);
final singboxConfigOption = persisted.toSingbox(
final singboxConfigOption = getConfigOptions().copyWith(
geoipPath: geoAssetPathResolver.relativePath(
geoAssets.geoip.providerName,
geoAssets.geoip.fileName,

View File

@@ -1,267 +0,0 @@
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hiddify/core/model/optional_range.dart';
import 'package:hiddify/core/utils/json_converters.dart';
import 'package:hiddify/features/log/model/log_level.dart';
import 'package:hiddify/singbox/model/singbox_config_enum.dart';
import 'package:hiddify/singbox/model/singbox_config_option.dart';
import 'package:hiddify/singbox/model/singbox_rule.dart';
import 'package:hiddify/utils/platform_utils.dart';
part 'config_option_entity.freezed.dart';
part 'config_option_entity.g.dart';
@freezed
class ConfigOptionEntity with _$ConfigOptionEntity {
const ConfigOptionEntity._();
@JsonSerializable(fieldRename: FieldRename.kebab)
const factory ConfigOptionEntity({
required ServiceMode serviceMode,
@Default(LogLevel.warn) LogLevel logLevel,
@Default(false) bool resolveDestination,
@Default(IPv6Mode.disable) IPv6Mode ipv6Mode,
@Default("udp://1.1.1.1") String remoteDnsAddress,
@Default(DomainStrategy.auto) DomainStrategy remoteDnsDomainStrategy,
@Default("1.1.1.1") String directDnsAddress,
@Default(DomainStrategy.auto) DomainStrategy directDnsDomainStrategy,
@Default(2334) int mixedPort,
@Default(6450) int localDnsPort,
@Default(TunImplementation.mixed) TunImplementation tunImplementation,
@Default(9000) int mtu,
@Default(true) bool strictRoute,
@Default("http://cp.cloudflare.com/") String connectionTestUrl,
@IntervalInSecondsConverter()
@Default(Duration(minutes: 10))
Duration urlTestInterval,
@Default(true) bool enableClashApi,
@Default(6756) int clashApiPort,
@Default(false) bool bypassLan,
@Default(false) bool allowConnectionFromLan,
@Default(false) bool enableFakeDns,
@Default(true) bool enableDnsRouting,
@Default(true) bool independentDnsCache,
@Default(false) bool enableTlsFragment,
@OptionalRangeJsonConverter()
@Default(OptionalRange(min: 1, max: 500))
OptionalRange tlsFragmentSize,
@OptionalRangeJsonConverter()
@Default(OptionalRange(min: 0, max: 500))
OptionalRange tlsFragmentSleep,
@Default(false) bool enableTlsMixedSniCase,
@Default(false) bool enableTlsPadding,
@OptionalRangeJsonConverter()
@Default(OptionalRange(min: 1, max: 1500))
OptionalRange tlsPaddingSize,
@Default(false) bool enableMux,
@Default(false) bool muxPadding,
@Default(8) int muxMaxStreams,
@Default(MuxProtocol.h2mux) MuxProtocol muxProtocol,
@Default(false) bool enableWarp,
@Default(WarpDetourMode.outbound) WarpDetourMode warpDetourMode,
@Default("") String warpLicenseKey,
@Default("") String warpAccountId,
@Default("") String warpAccessToken,
@Default("auto") String warpCleanIp,
@Default(0) int warpPort,
@OptionalRangeJsonConverter()
@Default(OptionalRange(min: 5, max: 10))
OptionalRange warpNoise,
@OptionalRangeJsonConverter()
@Default(OptionalRange(min: 20, max: 200))
OptionalRange warpNoiseDelay,
@Default("") String warpWireguardConfig,
}) = _ConfigOptionEntity;
factory ConfigOptionEntity.initial() => ConfigOptionEntity(
serviceMode: ServiceMode.defaultMode,
);
bool hasExperimentalOptions() {
if (PlatformUtils.isDesktop && serviceMode == ServiceMode.tun) {
return true;
}
if (enableTlsFragment ||
enableTlsMixedSniCase ||
enableTlsPadding ||
enableMux ||
enableWarp) {
return true;
}
return false;
}
String format() {
const encoder = JsonEncoder.withIndent(' ');
return encoder.convert(toJson());
}
ConfigOptionEntity patch(ConfigOptionPatch patch) {
return copyWith(
serviceMode: patch.serviceMode ?? serviceMode,
logLevel: patch.logLevel ?? logLevel,
resolveDestination: patch.resolveDestination ?? resolveDestination,
ipv6Mode: patch.ipv6Mode ?? ipv6Mode,
remoteDnsAddress: patch.remoteDnsAddress ?? remoteDnsAddress,
remoteDnsDomainStrategy:
patch.remoteDnsDomainStrategy ?? remoteDnsDomainStrategy,
directDnsAddress: patch.directDnsAddress ?? directDnsAddress,
directDnsDomainStrategy:
patch.directDnsDomainStrategy ?? directDnsDomainStrategy,
mixedPort: patch.mixedPort ?? mixedPort,
localDnsPort: patch.localDnsPort ?? localDnsPort,
tunImplementation: patch.tunImplementation ?? tunImplementation,
mtu: patch.mtu ?? mtu,
strictRoute: patch.strictRoute ?? strictRoute,
connectionTestUrl: patch.connectionTestUrl ?? connectionTestUrl,
urlTestInterval: patch.urlTestInterval ?? urlTestInterval,
enableClashApi: patch.enableClashApi ?? enableClashApi,
clashApiPort: patch.clashApiPort ?? clashApiPort,
bypassLan: patch.bypassLan ?? bypassLan,
allowConnectionFromLan:
patch.allowConnectionFromLan ?? allowConnectionFromLan,
enableFakeDns: patch.enableFakeDns ?? enableFakeDns,
enableDnsRouting: patch.enableDnsRouting ?? enableDnsRouting,
independentDnsCache: patch.independentDnsCache ?? independentDnsCache,
enableTlsFragment: patch.enableTlsFragment ?? enableTlsFragment,
tlsFragmentSize: patch.tlsFragmentSize ?? tlsFragmentSize,
tlsFragmentSleep: patch.tlsFragmentSleep ?? tlsFragmentSleep,
enableTlsMixedSniCase:
patch.enableTlsMixedSniCase ?? enableTlsMixedSniCase,
enableTlsPadding: patch.enableTlsPadding ?? enableTlsPadding,
tlsPaddingSize: patch.tlsPaddingSize ?? tlsPaddingSize,
enableMux: patch.enableMux ?? enableMux,
muxPadding: patch.muxPadding ?? muxPadding,
muxMaxStreams: patch.muxMaxStreams ?? muxMaxStreams,
muxProtocol: patch.muxProtocol ?? muxProtocol,
enableWarp: patch.enableWarp ?? enableWarp,
warpDetourMode: patch.warpDetourMode ?? warpDetourMode,
warpLicenseKey: patch.warpLicenseKey ?? warpLicenseKey,
warpAccountId: patch.warpAccountId ?? warpAccountId,
warpAccessToken: patch.warpAccessToken ?? warpAccessToken,
warpCleanIp: patch.warpCleanIp ?? warpCleanIp,
warpPort: patch.warpPort ?? warpPort,
warpNoise: patch.warpNoise ?? warpNoise,
warpNoiseDelay: patch.warpNoiseDelay ?? warpNoiseDelay,
warpWireguardConfig: patch.warpWireguardConfig ?? warpWireguardConfig,
);
}
SingboxConfigOption toSingbox({
required String geoipPath,
required String geositePath,
required List<SingboxRule> rules,
}) {
return SingboxConfigOption(
executeConfigAsIs: false,
logLevel: logLevel,
resolveDestination: resolveDestination,
ipv6Mode: ipv6Mode,
remoteDnsAddress: remoteDnsAddress,
remoteDnsDomainStrategy: remoteDnsDomainStrategy,
directDnsAddress: directDnsAddress,
directDnsDomainStrategy: directDnsDomainStrategy,
mixedPort: mixedPort,
localDnsPort: localDnsPort,
tunImplementation: tunImplementation,
mtu: mtu,
strictRoute: strictRoute,
connectionTestUrl: connectionTestUrl,
urlTestInterval: urlTestInterval,
enableClashApi: enableClashApi,
clashApiPort: clashApiPort,
enableTun: serviceMode == ServiceMode.tun,
enableTunService: serviceMode == ServiceMode.tunService,
setSystemProxy: serviceMode == ServiceMode.systemProxy,
bypassLan: bypassLan,
allowConnectionFromLan: allowConnectionFromLan,
enableFakeDns: enableFakeDns,
enableDnsRouting: enableDnsRouting,
independentDnsCache: independentDnsCache,
enableTlsFragment: enableTlsFragment,
tlsFragmentSize: tlsFragmentSize,
tlsFragmentSleep: tlsFragmentSleep,
enableTlsMixedSniCase: enableTlsMixedSniCase,
enableTlsPadding: enableTlsPadding,
tlsPaddingSize: tlsPaddingSize,
enableMux: enableMux,
muxPadding: muxPadding,
muxMaxStreams: muxMaxStreams,
muxProtocol: muxProtocol,
geoipPath: geoipPath,
geositePath: geositePath,
rules: rules,
warp: SingboxWarpOption(
enable: enableWarp,
mode: warpDetourMode,
licenseKey: warpLicenseKey,
accountId: warpAccountId,
accessToken: warpAccessToken,
cleanIp: warpCleanIp,
cleanPort: warpPort,
warpNoise: warpNoise,
warpNoiseDelay: warpNoiseDelay,
wireguardConfig: warpWireguardConfig,
),
);
}
factory ConfigOptionEntity.fromJson(Map<String, dynamic> json) =>
_$ConfigOptionEntityFromJson(json);
}
@freezed
class ConfigOptionPatch with _$ConfigOptionPatch {
const ConfigOptionPatch._();
@JsonSerializable(fieldRename: FieldRename.kebab)
const factory ConfigOptionPatch({
ServiceMode? serviceMode,
LogLevel? logLevel,
bool? resolveDestination,
IPv6Mode? ipv6Mode,
String? remoteDnsAddress,
DomainStrategy? remoteDnsDomainStrategy,
String? directDnsAddress,
DomainStrategy? directDnsDomainStrategy,
int? mixedPort,
int? localDnsPort,
TunImplementation? tunImplementation,
int? mtu,
bool? strictRoute,
String? connectionTestUrl,
@IntervalInSecondsConverter() Duration? urlTestInterval,
bool? enableClashApi,
int? clashApiPort,
bool? bypassLan,
bool? allowConnectionFromLan,
bool? enableFakeDns,
bool? enableDnsRouting,
bool? independentDnsCache,
bool? enableTlsFragment,
@OptionalRangeJsonConverter() OptionalRange? tlsFragmentSize,
@OptionalRangeJsonConverter() OptionalRange? tlsFragmentSleep,
bool? enableTlsMixedSniCase,
bool? enableTlsPadding,
@OptionalRangeJsonConverter() OptionalRange? tlsPaddingSize,
bool? enableMux,
bool? muxPadding,
int? muxMaxStreams,
MuxProtocol? muxProtocol,
bool? enableWarp,
WarpDetourMode? warpDetourMode,
String? warpLicenseKey,
String? warpAccountId,
String? warpAccessToken,
String? warpCleanIp,
int? warpPort,
@OptionalRangeJsonConverter() OptionalRange? warpNoise,
@OptionalRangeJsonConverter() OptionalRange? warpNoiseDelay,
String? warpWireguardConfig,
}) = _ConfigOptionPatch;
factory ConfigOptionPatch.fromJson(Map<String, dynamic> json) =>
_$ConfigOptionPatchFromJson(json);
}

View File

@@ -1,5 +1,7 @@
import 'package:hiddify/features/config_option/data/config_option_data_providers.dart';
import 'package:hiddify/features/config_option/model/config_option_entity.dart';
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:hiddify/features/config_option/data/config_option_repository.dart';
import 'package:hiddify/utils/custom_loggers.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -8,28 +10,22 @@ part 'config_option_notifier.g.dart';
@Riverpod(keepAlive: true)
class ConfigOptionNotifier extends _$ConfigOptionNotifier with AppLogger {
@override
Future<ConfigOptionEntity> build() async {
return ref
.watch(configOptionRepositoryProvider)
.getConfigOption()
.getOrElse((l) {
loggy.error("error getting persisted options $l", l);
throw l;
});
}
Future<void> build() async {}
Future<void> updateOption(ConfigOptionPatch patch) async {
if (state case AsyncData(value: final options)) {
await ref
.read(configOptionRepositoryProvider)
.updateConfigOption(patch)
.map((_) => state = AsyncData(options.patch(patch)))
.run();
}
Future<void> exportJsonToClipboard() async {
final map = {
for (final option in ConfigOptions.preferences)
ref.read(option.notifier).entry.key: ref.read(option.notifier).raw(),
};
const encoder = JsonEncoder.withIndent(' ');
final json = encoder.convert(map);
await Clipboard.setData(ClipboardData(text: json));
}
Future<void> resetOption() async {
await ref.read(configOptionRepositoryProvider).resetConfigOption().run();
for (final option in ConfigOptions.preferences) {
await ref.read(option.notifier).reset();
}
ref.invalidateSelf();
}
}

View File

@@ -1,7 +1,8 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hiddify/core/preferences/preferences_provider.dart';
import 'package:hiddify/features/config_option/data/config_option_data_providers.dart';
import 'package:hiddify/features/config_option/data/config_option_repository.dart';
import 'package:hiddify/features/config_option/model/config_option_failure.dart';
import 'package:hiddify/singbox/service/singbox_service_provider.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -46,15 +47,28 @@ class WarpOptionNotifier extends _$WarpOptionNotifier with AppLogger {
Future<void> generateWarpConfig() async {
if (state.configGeneration.isLoading) return;
state = state.copyWith(configGeneration: const AsyncLoading());
final result = await AsyncValue.guard(
() async => await ref
.read(configOptionRepositoryProvider)
.generateWarpConfig()
.getOrElse((l) {
loggy.warning("error generating warp config: $l", l);
throw l;
}).run(),
);
final result = await AsyncValue.guard(() async {
final warp = await ref
.read(singboxServiceProvider)
.generateWarpConfig(
licenseKey: ref.read(ConfigOptions.warpLicenseKey),
previousAccountId: ref.read(ConfigOptions.warpAccountId),
previousAccessToken: ref.read(ConfigOptions.warpAccessToken),
)
.getOrElse((l) => throw l)
.run();
await ref
.read(ConfigOptions.warpAccountId.notifier)
.update(warp.accountId);
await ref
.read(ConfigOptions.warpAccessToken.notifier)
.update(warp.accessToken);
return warp.log;
});
state = state.copyWith(configGeneration: result);
}

View File

@@ -1,17 +1,15 @@
import 'package:dartx/dartx.dart';
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gap/gap.dart';
import 'package:hiddify/core/localization/translations.dart';
import 'package:hiddify/core/model/failures.dart';
import 'package:hiddify/core/model/optional_range.dart';
import 'package:hiddify/core/widget/adaptive_icon.dart';
import 'package:hiddify/core/widget/tip_card.dart';
import 'package:hiddify/features/common/nested_app_bar.dart';
import 'package:hiddify/features/config_option/model/config_option_entity.dart';
import 'package:hiddify/features/config_option/data/config_option_repository.dart';
import 'package:hiddify/features/config_option/notifier/config_option_notifier.dart';
import 'package:hiddify/features/config_option/overview/warp_options_widgets.dart';
import 'package:hiddify/features/config_option/widget/preference_tile.dart';
import 'package:hiddify/features/log/model/log_level.dart';
import 'package:hiddify/features/settings/widgets/sections_widgets.dart';
import 'package:hiddify/features/settings/widgets/settings_input_dialog.dart';
@@ -27,13 +25,6 @@ class ConfigOptionsPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(translationsProvider);
final defaultOptions = ConfigOptionEntity.initial();
final asyncOptions = ref.watch(configOptionNotifierProvider);
Future<void> changeOption(ConfigOptionPatch patch) async {
await ref.read(configOptionNotifierProvider.notifier).updateOption(patch);
}
String experimental(String txt) {
return "$txt (${t.settings.experimental})";
}
@@ -44,462 +35,259 @@ class ConfigOptionsPage extends HookConsumerWidget {
NestedAppBar(
title: Text(t.settings.config.pageTitle),
actions: [
if (asyncOptions case AsyncData(value: final options))
PopupMenuButton(
icon: Icon(AdaptiveIcon(context).more),
itemBuilder: (context) {
return [
PopupMenuItem(
child: Text(t.general.addToClipboard),
onTap: () {
Clipboard.setData(
ClipboardData(text: options.format()),
);
},
),
PopupMenuItem(
child: Text(t.settings.config.resetBtn),
onTap: () async {
await ref
.read(configOptionNotifierProvider.notifier)
.resetOption();
},
),
];
},
),
],
),
switch (asyncOptions) {
AsyncData(value: final options) => SliverList.list(
children: [
TipCard(message: t.settings.experimentalMsg),
ListTile(
title: Text(t.settings.config.logLevel),
subtitle: Text(options.logLevel.name.toUpperCase()),
onTap: () async {
final logLevel = await SettingsPickerDialog(
title: t.settings.config.logLevel,
selected: options.logLevel,
options: LogLevel.choices,
getTitle: (e) => e.name.toUpperCase(),
resetValue: defaultOptions.logLevel,
).show(context);
if (logLevel == null) return;
await changeOption(ConfigOptionPatch(logLevel: logLevel));
},
),
const SettingsDivider(),
SettingsSection(t.settings.config.section.route),
SwitchListTile(
title: Text(experimental(t.settings.config.bypassLan)),
value: options.bypassLan,
onChanged: (value) async =>
changeOption(ConfigOptionPatch(bypassLan: value)),
),
SwitchListTile(
title: Text(t.settings.config.resolveDestination),
value: options.resolveDestination,
onChanged: (value) async => changeOption(
ConfigOptionPatch(resolveDestination: value),
PopupMenuButton(
icon: Icon(AdaptiveIcon(context).more),
itemBuilder: (context) {
return [
PopupMenuItem(
onTap: ref
.read(configOptionNotifierProvider.notifier)
.exportJsonToClipboard,
child: Text(t.general.addToClipboard),
),
),
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: defaultOptions.ipv6Mode,
).show(context);
if (ipv6Mode == null) return;
await changeOption(ConfigOptionPatch(ipv6Mode: 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: defaultOptions.remoteDnsAddress,
).show(context);
if (url == null || url.isEmpty) return;
await changeOption(
ConfigOptionPatch(remoteDnsAddress: 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: defaultOptions.remoteDnsDomainStrategy,
).show(context);
if (domainStrategy == null) return;
await changeOption(
ConfigOptionPatch(
remoteDnsDomainStrategy: 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: defaultOptions.directDnsAddress,
).show(context);
if (url == null || url.isEmpty) return;
await changeOption(
ConfigOptionPatch(directDnsAddress: 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: defaultOptions.directDnsDomainStrategy,
).show(context);
if (domainStrategy == null) return;
await changeOption(
ConfigOptionPatch(
directDnsDomainStrategy: domainStrategy,
),
);
},
),
SwitchListTile(
title: Text(t.settings.config.enableDnsRouting),
value: options.enableDnsRouting,
onChanged: (value) => changeOption(
ConfigOptionPatch(enableDnsRouting: value),
),
),
const SettingsDivider(),
SettingsSection(experimental(t.settings.config.section.mux)),
SwitchListTile(
title: Text(t.settings.config.enableMux),
value: options.enableMux,
onChanged: (value) => changeOption(
ConfigOptionPatch(enableMux: value),
),
),
ListTile(
title: Text(t.settings.config.muxProtocol),
subtitle: Text(options.muxProtocol.name),
onTap: () async {
final pickedProtocol = await SettingsPickerDialog(
title: t.settings.config.muxProtocol,
selected: options.muxProtocol,
options: MuxProtocol.values,
getTitle: (e) => e.name,
resetValue: defaultOptions.muxProtocol,
).show(context);
if (pickedProtocol == null) return;
await changeOption(
ConfigOptionPatch(muxProtocol: pickedProtocol),
);
},
),
ListTile(
title: Text(t.settings.config.muxMaxStreams),
subtitle: Text(options.muxMaxStreams.toString()),
onTap: () async {
final maxStreams = await SettingsInputDialog(
title: t.settings.config.muxMaxStreams,
initialValue: options.muxMaxStreams,
resetValue: defaultOptions.muxMaxStreams,
mapTo: int.tryParse,
digitsOnly: true,
).show(context);
if (maxStreams == null || maxStreams < 1) return;
await changeOption(
ConfigOptionPatch(muxMaxStreams: maxStreams),
);
},
),
const SettingsDivider(),
SettingsSection(t.settings.config.section.inbound),
ListTile(
title: Text(t.settings.config.serviceMode),
subtitle: Text(options.serviceMode.present(t)),
onTap: () async {
final pickedMode = await SettingsPickerDialog(
title: t.settings.config.serviceMode,
selected: options.serviceMode,
options: ServiceMode.choices,
getTitle: (e) => e.present(t),
resetValue: ServiceMode.defaultMode,
).show(context);
if (pickedMode == null) return;
await changeOption(
ConfigOptionPatch(serviceMode: pickedMode),
);
},
),
SwitchListTile(
title: Text(t.settings.config.strictRoute),
value: options.strictRoute,
onChanged: (value) async =>
changeOption(ConfigOptionPatch(strictRoute: value)),
),
ListTile(
title: Text(t.settings.config.tunImplementation),
subtitle: Text(options.tunImplementation.name),
onTap: () async {
final tunImplementation = await SettingsPickerDialog(
title: t.settings.config.tunImplementation,
selected: options.tunImplementation,
options: TunImplementation.values,
getTitle: (e) => e.name,
resetValue: defaultOptions.tunImplementation,
).show(context);
if (tunImplementation == null) return;
await changeOption(
ConfigOptionPatch(tunImplementation: tunImplementation),
);
},
),
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: defaultOptions.mixedPort,
validator: isPort,
mapTo: int.tryParse,
digitsOnly: true,
).show(context);
if (mixedPort == null) return;
await changeOption(
ConfigOptionPatch(mixedPort: 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: defaultOptions.localDnsPort,
validator: isPort,
mapTo: int.tryParse,
digitsOnly: true,
).show(context);
if (localDnsPort == null) return;
await changeOption(
ConfigOptionPatch(localDnsPort: localDnsPort),
);
},
),
SwitchListTile(
title: Text(
experimental(t.settings.config.allowConnectionFromLan),
),
value: options.allowConnectionFromLan,
onChanged: (value) => changeOption(
ConfigOptionPatch(allowConnectionFromLan: value),
),
),
const SettingsDivider(),
SettingsSection(t.settings.config.section.tlsTricks),
SwitchListTile(
title:
Text(experimental(t.settings.config.enableTlsFragment)),
value: options.enableTlsFragment,
onChanged: (value) async => changeOption(
ConfigOptionPatch(enableTlsFragment: value),
),
),
ListTile(
title: Text(t.settings.config.tlsFragmentSize),
subtitle: Text(options.tlsFragmentSize.present(t)),
onTap: () async {
final range = await SettingsInputDialog(
title: t.settings.config.tlsFragmentSize,
initialValue: options.tlsFragmentSize.format(),
resetValue: defaultOptions.tlsFragmentSize.format(),
).show(context);
if (range == null) return;
await changeOption(
ConfigOptionPatch(
tlsFragmentSize: OptionalRange.tryParse(range),
),
);
},
),
ListTile(
title: Text(t.settings.config.tlsFragmentSleep),
subtitle: Text(options.tlsFragmentSleep.present(t)),
onTap: () async {
final range = await SettingsInputDialog(
title: t.settings.config.tlsFragmentSleep,
initialValue: options.tlsFragmentSleep.format(),
resetValue: defaultOptions.tlsFragmentSleep.format(),
).show(context);
if (range == null) return;
await changeOption(
ConfigOptionPatch(
tlsFragmentSleep: OptionalRange.tryParse(range),
),
);
},
),
SwitchListTile(
title: Text(
experimental(t.settings.config.enableTlsMixedSniCase),
),
value: options.enableTlsMixedSniCase,
onChanged: (value) async => changeOption(
ConfigOptionPatch(enableTlsMixedSniCase: value),
),
),
SwitchListTile(
title:
Text(experimental(t.settings.config.enableTlsPadding)),
value: options.enableTlsPadding,
onChanged: (value) async => changeOption(
ConfigOptionPatch(enableTlsPadding: value),
),
),
ListTile(
title: Text(t.settings.config.tlsPaddingSize),
subtitle: Text(options.tlsPaddingSize.present(t)),
onTap: () async {
final range = await SettingsInputDialog(
title: t.settings.config.tlsPaddingSize,
initialValue: options.tlsPaddingSize.format(),
resetValue: defaultOptions.tlsPaddingSize.format(),
).show(context);
if (range == null) return;
await changeOption(
ConfigOptionPatch(
tlsPaddingSize: OptionalRange.tryParse(range),
),
);
},
),
const SettingsDivider(),
SettingsSection(experimental(t.settings.config.section.warp)),
WarpOptionsTiles(
options: options,
defaultOptions: defaultOptions,
onChange: changeOption,
),
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: defaultOptions.connectionTestUrl,
).show(context);
if (url == null || url.isEmpty || !isUrl(url)) return;
await changeOption(
ConfigOptionPatch(connectionTestUrl: url),
);
},
),
ListTile(
title: Text(t.settings.config.urlTestInterval),
subtitle: Text(
options.urlTestInterval
.toApproximateTime(isRelativeToNow: false),
),
onTap: () async {
final urlTestInterval = await SettingsSliderDialog(
title: t.settings.config.urlTestInterval,
initialValue: options.urlTestInterval.inMinutes
.coerceIn(0, 60)
.toDouble(),
resetValue:
defaultOptions.urlTestInterval.inMinutes.toDouble(),
min: 1,
max: 60,
divisions: 60,
labelGen: (value) => Duration(minutes: value.toInt())
.toApproximateTime(isRelativeToNow: false),
).show(context);
if (urlTestInterval == null) return;
await changeOption(
ConfigOptionPatch(
urlTestInterval:
Duration(minutes: urlTestInterval.toInt()),
),
);
},
),
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: defaultOptions.clashApiPort,
validator: isPort,
mapTo: int.tryParse,
digitsOnly: true,
).show(context);
if (clashApiPort == null) return;
await changeOption(
ConfigOptionPatch(clashApiPort: clashApiPort),
);
},
),
const Gap(24),
],
),
AsyncError(:final error) => SliverFillRemaining(
hasScrollBody: false,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(FluentIcons.error_circle_24_regular),
const Gap(2),
Text(t.presentShortError(error)),
const Gap(2),
TextButton(
onPressed: () async {
PopupMenuItem(
child: Text(t.settings.config.resetBtn),
onTap: () async {
await ref
.read(configOptionNotifierProvider.notifier)
.resetOption();
},
child: Text(t.settings.config.resetBtn),
),
],
),
];
},
),
_ => const SliverToBoxAdapter(),
},
],
),
SliverList.list(
children: [
TipCard(message: t.settings.experimentalMsg),
ChoicePreferenceWidget(
selected: ref.watch(ConfigOptions.logLevel),
preferences: ref.watch(ConfigOptions.logLevel.notifier),
choices: LogLevel.choices,
title: t.settings.config.logLevel,
presentChoice: (value) => value.name.toUpperCase(),
),
const SettingsDivider(),
SettingsSection(t.settings.config.section.route),
SwitchListTile(
title: Text(experimental(t.settings.config.bypassLan)),
value: ref.watch(ConfigOptions.bypassLan),
onChanged: ref.watch(ConfigOptions.bypassLan.notifier).update,
),
SwitchListTile(
title: Text(t.settings.config.resolveDestination),
value: ref.watch(ConfigOptions.resolveDestination),
onChanged:
ref.watch(ConfigOptions.resolveDestination.notifier).update,
),
ChoicePreferenceWidget(
selected: ref.watch(ConfigOptions.ipv6Mode),
preferences: ref.watch(ConfigOptions.ipv6Mode.notifier),
choices: IPv6Mode.values,
title: t.settings.config.ipv6Mode,
presentChoice: (value) => value.present(t),
),
const SettingsDivider(),
SettingsSection(t.settings.config.section.dns),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.remoteDnsAddress),
preferences: ref.watch(ConfigOptions.remoteDnsAddress.notifier),
title: t.settings.config.remoteDnsAddress,
),
ChoicePreferenceWidget(
selected: ref.watch(ConfigOptions.remoteDnsDomainStrategy),
preferences:
ref.watch(ConfigOptions.remoteDnsDomainStrategy.notifier),
choices: DomainStrategy.values,
title: t.settings.config.remoteDnsDomainStrategy,
presentChoice: (value) => value.displayName,
),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.directDnsAddress),
preferences: ref.watch(ConfigOptions.directDnsAddress.notifier),
title: t.settings.config.directDnsAddress,
),
ChoicePreferenceWidget(
selected: ref.watch(ConfigOptions.directDnsDomainStrategy),
preferences:
ref.watch(ConfigOptions.directDnsDomainStrategy.notifier),
choices: DomainStrategy.values,
title: t.settings.config.directDnsDomainStrategy,
presentChoice: (value) => value.displayName,
),
SwitchListTile(
title: Text(t.settings.config.enableDnsRouting),
value: ref.watch(ConfigOptions.enableDnsRouting),
onChanged:
ref.watch(ConfigOptions.enableDnsRouting.notifier).update,
),
const SettingsDivider(),
SettingsSection(experimental(t.settings.config.section.mux)),
SwitchListTile(
title: Text(t.settings.config.enableMux),
value: ref.watch(ConfigOptions.enableMux),
onChanged: ref.watch(ConfigOptions.enableMux.notifier).update,
),
ChoicePreferenceWidget(
selected: ref.watch(ConfigOptions.muxProtocol),
preferences: ref.watch(ConfigOptions.muxProtocol.notifier),
choices: MuxProtocol.values,
title: t.settings.config.muxProtocol,
presentChoice: (value) => value.name,
),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.muxMaxStreams),
preferences: ref.watch(ConfigOptions.muxMaxStreams.notifier),
title: t.settings.config.muxMaxStreams,
inputToValue: int.tryParse,
digitsOnly: true,
),
const SettingsDivider(),
SettingsSection(t.settings.config.section.inbound),
ChoicePreferenceWidget(
selected: ref.watch(ConfigOptions.serviceMode),
preferences: ref.watch(ConfigOptions.serviceMode.notifier),
choices: ServiceMode.choices,
title: t.settings.config.serviceMode,
presentChoice: (value) => value.present(t),
),
SwitchListTile(
title: Text(t.settings.config.strictRoute),
value: ref.watch(ConfigOptions.strictRoute),
onChanged: ref.watch(ConfigOptions.strictRoute.notifier).update,
),
ChoicePreferenceWidget(
selected: ref.watch(ConfigOptions.tunImplementation),
preferences:
ref.watch(ConfigOptions.tunImplementation.notifier),
choices: TunImplementation.values,
title: t.settings.config.tunImplementation,
presentChoice: (value) => value.name,
),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.mixedPort),
preferences: ref.watch(ConfigOptions.mixedPort.notifier),
title: t.settings.config.mixedPort,
inputToValue: int.tryParse,
digitsOnly: true,
validateInput: isPort,
),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.localDnsPort),
preferences: ref.watch(ConfigOptions.localDnsPort.notifier),
title: t.settings.config.localDnsPort,
inputToValue: int.tryParse,
digitsOnly: true,
validateInput: isPort,
),
SwitchListTile(
title: Text(
experimental(t.settings.config.allowConnectionFromLan),
),
value: ref.watch(ConfigOptions.allowConnectionFromLan),
onChanged: ref
.read(ConfigOptions.allowConnectionFromLan.notifier)
.update,
),
const SettingsDivider(),
SettingsSection(t.settings.config.section.tlsTricks),
SwitchListTile(
title: Text(experimental(t.settings.config.enableTlsFragment)),
value: ref.watch(ConfigOptions.enableTlsFragment),
onChanged:
ref.watch(ConfigOptions.enableTlsFragment.notifier).update,
),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.tlsFragmentSize),
preferences: ref.watch(ConfigOptions.tlsFragmentSize.notifier),
title: t.settings.config.tlsFragmentSize,
inputToValue: OptionalRange.tryParse,
presentValue: (value) => value.present(t),
formatInputValue: (value) => value.format(),
),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.tlsFragmentSleep),
preferences: ref.watch(ConfigOptions.tlsFragmentSleep.notifier),
title: t.settings.config.tlsFragmentSleep,
inputToValue: OptionalRange.tryParse,
presentValue: (value) => value.present(t),
formatInputValue: (value) => value.format(),
),
SwitchListTile(
title: Text(
experimental(t.settings.config.enableTlsMixedSniCase),
),
value: ref.watch(ConfigOptions.enableTlsMixedSniCase),
onChanged: ref
.watch(ConfigOptions.enableTlsMixedSniCase.notifier)
.update,
),
SwitchListTile(
title: Text(experimental(t.settings.config.enableTlsPadding)),
value: ref.watch(ConfigOptions.enableTlsPadding),
onChanged:
ref.watch(ConfigOptions.enableTlsPadding.notifier).update,
),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.tlsPaddingSize),
preferences: ref.watch(ConfigOptions.tlsPaddingSize.notifier),
title: t.settings.config.tlsPaddingSize,
inputToValue: OptionalRange.tryParse,
presentValue: (value) => value.format(),
formatInputValue: (value) => value.format(),
),
const SettingsDivider(),
SettingsSection(experimental(t.settings.config.section.warp)),
const WarpOptionsTiles(),
const SettingsDivider(),
SettingsSection(t.settings.config.section.misc),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.connectionTestUrl),
preferences:
ref.watch(ConfigOptions.connectionTestUrl.notifier),
title: t.settings.config.connectionTestUrl,
),
ListTile(
title: Text(t.settings.config.urlTestInterval),
subtitle: Text(
ref
.watch(ConfigOptions.urlTestInterval)
.toApproximateTime(isRelativeToNow: false),
),
onTap: () async {
final urlTestInterval = await SettingsSliderDialog(
title: t.settings.config.urlTestInterval,
initialValue: ref
.watch(ConfigOptions.urlTestInterval)
.inMinutes
.coerceIn(0, 60)
.toDouble(),
onReset:
ref.read(ConfigOptions.urlTestInterval.notifier).reset,
min: 1,
max: 60,
divisions: 60,
labelGen: (value) => Duration(minutes: value.toInt())
.toApproximateTime(isRelativeToNow: false),
).show(context);
if (urlTestInterval == null) return;
await ref
.read(ConfigOptions.urlTestInterval.notifier)
.update(Duration(minutes: urlTestInterval.toInt()));
},
),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.clashApiPort),
preferences: ref.watch(ConfigOptions.clashApiPort.notifier),
title: t.settings.config.clashApiPort,
validateInput: isPort,
digitsOnly: true,
inputToValue: int.tryParse,
),
const Gap(24),
],
),
],
),
);

View File

@@ -1,29 +1,19 @@
import 'package:dartx/dartx.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:hiddify/core/localization/translations.dart';
import 'package:hiddify/core/model/constants.dart';
import 'package:hiddify/core/model/optional_range.dart';
import 'package:hiddify/core/widget/custom_alert_dialog.dart';
import 'package:hiddify/features/config_option/model/config_option_entity.dart';
import 'package:hiddify/features/config_option/data/config_option_repository.dart';
import 'package:hiddify/features/config_option/notifier/warp_option_notifier.dart';
import 'package:hiddify/features/settings/widgets/settings_input_dialog.dart';
import 'package:hiddify/features/config_option/widget/preference_tile.dart';
import 'package:hiddify/singbox/model/singbox_config_enum.dart';
import 'package:hiddify/utils/uri_utils.dart';
import 'package:hiddify/utils/validators.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class WarpOptionsTiles extends HookConsumerWidget {
const WarpOptionsTiles({
required this.options,
required this.defaultOptions,
required this.onChange,
super.key,
});
final ConfigOptionEntity options;
final ConfigOptionEntity defaultOptions;
final Future<void> Function(ConfigOptionPatch patch) onChange;
const WarpOptionsTiles({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -31,7 +21,8 @@ class WarpOptionsTiles extends HookConsumerWidget {
final warpOptions = ref.watch(warpOptionNotifierProvider);
final warpPrefaceCompleted = warpOptions.consentGiven;
final canChangeOptions = warpPrefaceCompleted && options.enableWarp;
final enableWarp = ref.watch(ConfigOptions.enableWarp);
final canChangeOptions = warpPrefaceCompleted && enableWarp;
ref.listen(
warpOptionNotifierProvider.select((value) => value.configGeneration),
@@ -49,7 +40,7 @@ class WarpOptionsTiles extends HookConsumerWidget {
children: [
SwitchListTile(
title: Text(t.settings.config.enableWarp),
value: options.enableWarp,
value: enableWarp,
onChanged: (value) async {
if (!warpPrefaceCompleted) {
final agreed = await showDialog<bool>(
@@ -58,10 +49,10 @@ class WarpOptionsTiles extends HookConsumerWidget {
);
if (agreed ?? false) {
await ref.read(warpOptionNotifierProvider.notifier).agree();
await onChange(ConfigOptionPatch(enableWarp: value));
await ref.read(ConfigOptions.enableWarp.notifier).update(value);
}
} else {
await onChange(ConfigOptionPatch(enableWarp: value));
await ref.read(ConfigOptions.enableWarp.notifier).update(value);
}
},
),
@@ -85,112 +76,56 @@ class WarpOptionsTiles extends HookConsumerWidget {
.generateWarpConfig();
},
),
ListTile(
title: Text(t.settings.config.warpDetourMode),
subtitle: Text(options.warpDetourMode.present(t)),
ChoicePreferenceWidget(
selected: ref.watch(ConfigOptions.warpDetourMode),
preferences: ref.watch(ConfigOptions.warpDetourMode.notifier),
enabled: canChangeOptions,
onTap: () async {
final warpDetourMode = await SettingsPickerDialog(
title: t.settings.config.warpDetourMode,
selected: options.warpDetourMode,
options: WarpDetourMode.values,
getTitle: (e) => e.present(t),
resetValue: defaultOptions.warpDetourMode,
).show(context);
if (warpDetourMode == null) return;
await onChange(
ConfigOptionPatch(warpDetourMode: warpDetourMode),
);
},
choices: WarpDetourMode.values,
title: t.settings.config.warpDetourMode,
presentChoice: (value) => value.present(t),
),
ListTile(
title: Text(t.settings.config.warpLicenseKey),
subtitle: Text(
options.warpLicenseKey.isEmpty
? t.general.notSet
: options.warpLicenseKey,
),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.warpLicenseKey),
preferences: ref.watch(ConfigOptions.warpLicenseKey.notifier),
enabled: canChangeOptions,
onTap: () async {
final licenseKey = await SettingsInputDialog(
title: t.settings.config.warpLicenseKey,
initialValue: options.warpLicenseKey,
resetValue: defaultOptions.warpLicenseKey,
).show(context);
if (licenseKey == null) return;
await onChange(ConfigOptionPatch(warpLicenseKey: licenseKey));
},
title: t.settings.config.warpLicenseKey,
presentValue: (value) => value.isEmpty ? t.general.notSet : value,
),
ListTile(
title: Text(t.settings.config.warpCleanIp),
subtitle: Text(options.warpCleanIp),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.warpCleanIp),
preferences: ref.watch(ConfigOptions.warpCleanIp.notifier),
enabled: canChangeOptions,
onTap: () async {
final warpCleanIp = await SettingsInputDialog(
title: t.settings.config.warpCleanIp,
initialValue: options.warpCleanIp,
resetValue: defaultOptions.warpCleanIp,
).show(context);
if (warpCleanIp == null || warpCleanIp.isBlank) return;
await onChange(ConfigOptionPatch(warpCleanIp: warpCleanIp));
},
title: t.settings.config.warpCleanIp,
),
ListTile(
title: Text(t.settings.config.warpPort),
subtitle: Text(options.warpPort.toString()),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.warpPort),
preferences: ref.watch(ConfigOptions.warpPort.notifier),
enabled: canChangeOptions,
onTap: () async {
final warpPort = await SettingsInputDialog(
title: t.settings.config.warpPort,
initialValue: options.warpPort,
resetValue: defaultOptions.warpPort,
validator: isPort,
mapTo: int.tryParse,
digitsOnly: true,
).show(context);
if (warpPort == null) return;
await onChange(
ConfigOptionPatch(warpPort: warpPort),
);
},
title: t.settings.config.warpPort,
inputToValue: int.tryParse,
validateInput: isPort,
digitsOnly: true,
),
ListTile(
title: Text(t.settings.config.warpNoise),
subtitle: Text(options.warpNoise.present(t)),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.warpNoise),
preferences: ref.watch(ConfigOptions.warpNoise.notifier),
enabled: canChangeOptions,
onTap: () async {
final warpNoise = await SettingsInputDialog(
title: t.settings.config.warpNoise,
initialValue: options.warpNoise.format(),
resetValue: defaultOptions.warpNoise.format(),
).show(context);
if (warpNoise == null) return;
await onChange(
ConfigOptionPatch(
warpNoise: OptionalRange.tryParse(warpNoise, allowEmpty: true),
),
);
},
title: t.settings.config.warpNoise,
inputToValue: (input) =>
OptionalRange.tryParse(input, allowEmpty: true),
presentValue: (value) => value.present(t),
formatInputValue: (value) => value.format(),
),
ListTile(
title: Text(t.settings.config.warpNoiseDelay),
subtitle: Text(options.warpNoiseDelay.present(t)),
ValuePreferenceWidget(
value: ref.watch(ConfigOptions.warpNoiseDelay),
preferences: ref.watch(ConfigOptions.warpNoiseDelay.notifier),
enabled: canChangeOptions,
onTap: () async {
final warpNoiseDelay = await SettingsInputDialog(
title: t.settings.config.warpNoiseDelay,
initialValue: options.warpNoiseDelay.format(),
resetValue: defaultOptions.warpNoiseDelay.format(),
).show(context);
if (warpNoiseDelay == null) return;
await onChange(
ConfigOptionPatch(
warpNoiseDelay:
OptionalRange.tryParse(warpNoiseDelay, allowEmpty: true),
),
);
},
)
title: t.settings.config.warpNoiseDelay,
inputToValue: (input) =>
OptionalRange.tryParse(input, allowEmpty: true),
presentValue: (value) => value.present(t),
formatInputValue: (value) => value.format(),
),
],
);
}

View File

@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:hiddify/core/utils/preferences_utils.dart';
import 'package:hiddify/features/settings/widgets/widgets.dart';
class ValuePreferenceWidget<T> extends StatelessWidget {
const ValuePreferenceWidget({
super.key,
required this.value,
required this.preferences,
this.enabled = true,
required this.title,
this.presentValue,
this.formatInputValue,
this.validateInput,
this.inputToValue,
this.digitsOnly = false,
});
final T value;
final PreferencesNotifier<T, dynamic> preferences;
final bool enabled;
final String title;
final String Function(T value)? presentValue;
final String Function(T value)? formatInputValue;
final bool Function(String value)? validateInput;
final T? Function(String input)? inputToValue;
final bool digitsOnly;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
subtitle: Text(presentValue?.call(value) ?? value.toString()),
enabled: enabled,
onTap: () async {
final inputValue = await SettingsInputDialog(
title: title,
initialValue: value,
validator: validateInput,
valueFormatter: formatInputValue,
onReset: preferences.reset,
digitsOnly: digitsOnly,
mapTo: inputToValue,
).show(context);
if (inputValue == null) {
return;
}
await preferences.update(inputValue);
},
);
}
}
class ChoicePreferenceWidget<T> extends StatelessWidget {
const ChoicePreferenceWidget({
super.key,
required this.selected,
required this.preferences,
this.enabled = true,
required this.choices,
required this.title,
required this.presentChoice,
this.validateInput,
});
final T selected;
final PreferencesNotifier<T, dynamic> preferences;
final bool enabled;
final List<T> choices;
final String title;
final String Function(T value) presentChoice;
final bool Function(String value)? validateInput;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
subtitle: Text(presentChoice(selected)),
enabled: enabled,
onTap: () async {
final selection = await SettingsPickerDialog(
title: title,
selected: selected,
options: choices,
getTitle: (e) => presentChoice(e),
onReset: preferences.reset,
).show(context);
if (selection == null) {
return;
}
await preferences.update(selection);
},
);
}
}