Add cloudflare warp options

This commit is contained in:
problematicconsumer
2024-02-03 12:36:27 +03:30
parent 18f1f389e0
commit 11defeb010
16 changed files with 426 additions and 22 deletions

View File

@@ -10,4 +10,8 @@ abstract class Constants {
static const telegramChannelUrl = "https://t.me/hiddify";
static const privacyPolicyUrl = "https://hiddify.com/privacy-policy/";
static const termsAndConditionsUrl = "https://hiddify.com/terms/";
static const cfWarpPrivacyPolicy =
"https://www.cloudflare.com/application/privacypolicy/";
static const cfWarpTermsOfService =
"https://www.cloudflare.com/application/terms/";
}

View File

@@ -1,4 +1,6 @@
import 'package:dartx/dartx.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hiddify/core/localization/translations.dart';
part 'range.freezed.dart';
@@ -7,14 +9,21 @@ class RangeWithOptionalCeil with _$RangeWithOptionalCeil {
const RangeWithOptionalCeil._();
const factory RangeWithOptionalCeil({
required int min,
int? min,
int? max,
}) = _RangeWithOptionalCeil;
String format() => "$min${max != null ? "-$max" : ""}";
String format() => [min, max].whereNotNull().join("-");
String present(TranslationsEn t) =>
format().isEmpty ? t.general.notSet : format();
factory RangeWithOptionalCeil.fromString(String input) =>
factory RangeWithOptionalCeil._fromString(
String input, {
bool allowEmpty = true,
}) =>
switch (input.split("-")) {
[final String val] when val.isEmpty && allowEmpty =>
const RangeWithOptionalCeil(),
[final String min] => RangeWithOptionalCeil(min: int.parse(min)),
[final String min, final String max] => RangeWithOptionalCeil(
min: int.parse(min),
@@ -23,9 +32,12 @@ class RangeWithOptionalCeil with _$RangeWithOptionalCeil {
_ => throw Exception("Invalid range: $input"),
};
static RangeWithOptionalCeil? tryParse(String input) {
static RangeWithOptionalCeil? tryParse(
String input, {
bool allowEmpty = false,
}) {
try {
return RangeWithOptionalCeil.fromString(input);
return RangeWithOptionalCeil._fromString(input);
} catch (_) {
return null;
}
@@ -38,7 +50,7 @@ class RangeWithOptionalCeilJsonConverter
@override
RangeWithOptionalCeil fromJson(String json) =>
RangeWithOptionalCeil.fromString(json);
RangeWithOptionalCeil._fromString(json);
@override
String toJson(RangeWithOptionalCeil object) => object.format();

View File

@@ -117,6 +117,12 @@ class ConfigOptionRepositoryImpl
muxPadding: persisted.muxPadding,
muxMaxStreams: persisted.muxMaxStreams,
muxProtocol: persisted.muxProtocol,
enableWarp: persisted.enableWarp,
warpDetourMode: persisted.warpDetourMode,
warpLicenseKey: persisted.warpLicenseKey,
warpCleanIp: persisted.warpCleanIp,
warpPort: persisted.warpPort,
warpNoise: persisted.warpNoise,
geoipPath: geoAssetPathResolver.relativePath(
geoAssets.geoip.providerName,
geoAssets.geoip.fileName,

View File

@@ -57,6 +57,14 @@ class ConfigOptionEntity with _$ConfigOptionEntity {
@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("auto") String warpCleanIp,
@Default(0) int warpPort,
@RangeWithOptionalCeilJsonConverter()
@Default(RangeWithOptionalCeil())
RangeWithOptionalCeil warpNoise,
}) = _ConfigOptionEntity;
static ConfigOptionEntity initial = ConfigOptionEntity(
@@ -67,7 +75,11 @@ class ConfigOptionEntity with _$ConfigOptionEntity {
if (PlatformUtils.isDesktop && serviceMode == ServiceMode.tun) {
return true;
}
if (enableTlsFragment || enableTlsMixedSniCase || enableTlsPadding||enableMux) {
if (enableTlsFragment ||
enableTlsMixedSniCase ||
enableTlsPadding ||
enableMux ||
enableWarp) {
return true;
}
@@ -117,6 +129,12 @@ class ConfigOptionEntity with _$ConfigOptionEntity {
muxPadding: patch.muxPadding ?? muxPadding,
muxMaxStreams: patch.muxMaxStreams ?? muxMaxStreams,
muxProtocol: patch.muxProtocol ?? muxProtocol,
enableWarp: patch.enableWarp ?? enableWarp,
warpDetourMode: patch.warpDetourMode ?? warpDetourMode,
warpLicenseKey: patch.warpLicenseKey ?? warpLicenseKey,
warpCleanIp: patch.warpCleanIp ?? warpCleanIp,
warpPort: patch.warpPort ?? warpPort,
warpNoise: patch.warpNoise ?? warpNoise,
);
}

View File

@@ -47,6 +47,12 @@ class ConfigOptionPatch with _$ConfigOptionPatch {
bool? muxPadding,
int? muxMaxStreams,
MuxProtocol? muxProtocol,
bool? enableWarp,
WarpDetourMode? warpDetourMode,
String? warpLicenseKey,
String? warpCleanIp,
int? warpPort,
@RangeWithOptionalCeilJsonConverter() RangeWithOptionalCeil? warpNoise,
}) = _ConfigOptionPatch;
factory ConfigOptionPatch.fromJson(Map<String, dynamic> json) =>

View File

@@ -0,0 +1,26 @@
import 'package:hiddify/core/preferences/preferences_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'warp_option_notifier.g.dart';
@Riverpod(keepAlive: true)
class WarpOptionNotifier extends _$WarpOptionNotifier {
@override
bool build() {
return ref
.read(sharedPreferencesProvider)
.requireValue
.getBool(warpConsentGiven) ??
false;
}
Future<void> agree() async {
await ref
.read(sharedPreferencesProvider)
.requireValue
.setBool(warpConsentGiven, true);
state = true;
}
static const warpConsentGiven = "warp_consent_given";
}

View File

@@ -9,6 +9,7 @@ import 'package:hiddify/core/widget/tip_card.dart';
import 'package:hiddify/features/config_option/model/config_option_entity.dart';
import 'package:hiddify/features/config_option/model/config_option_patch.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/log/model/log_level.dart';
import 'package:hiddify/features/settings/widgets/sections_widgets.dart';
import 'package:hiddify/features/settings/widgets/settings_input_dialog.dart';
@@ -319,7 +320,7 @@ class ConfigOptionsPage extends HookConsumerWidget {
),
ListTile(
title: Text(t.settings.config.tlsFragmentSize),
subtitle: Text(options.tlsFragmentSize.format()),
subtitle: Text(options.tlsFragmentSize.present(t)),
onTap: () async {
final range = await SettingsInputDialog(
title: t.settings.config.tlsFragmentSize,
@@ -336,7 +337,7 @@ class ConfigOptionsPage extends HookConsumerWidget {
),
ListTile(
title: Text(t.settings.config.tlsFragmentSleep),
subtitle: Text(options.tlsFragmentSleep.format()),
subtitle: Text(options.tlsFragmentSleep.present(t)),
onTap: () async {
final range = await SettingsInputDialog(
title: t.settings.config.tlsFragmentSleep,
@@ -368,7 +369,7 @@ class ConfigOptionsPage extends HookConsumerWidget {
),
ListTile(
title: Text(t.settings.config.tlsPaddingSize),
subtitle: Text(options.tlsPaddingSize.format()),
subtitle: Text(options.tlsPaddingSize.present(t)),
onTap: () async {
final range = await SettingsInputDialog(
title: t.settings.config.tlsPaddingSize,
@@ -384,6 +385,13 @@ class ConfigOptionsPage extends HookConsumerWidget {
},
),
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),

View File

@@ -0,0 +1,199 @@
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/range.dart';
import 'package:hiddify/features/config_option/model/config_option_entity.dart';
import 'package:hiddify/features/config_option/model/config_option_patch.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/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;
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(translationsProvider);
final warpPrefaceCompleted = ref.watch(warpOptionNotifierProvider);
final canChangeOptions = warpPrefaceCompleted && options.enableWarp;
return Column(
children: [
SwitchListTile.adaptive(
title: Text(t.settings.config.enableWarp),
value: options.enableWarp,
onChanged: (value) async {
if (!warpPrefaceCompleted) {
final agreed = await showAdaptiveDialog<bool>(
context: context,
builder: (context) => const WarpLicenseAgreementModal(),
);
if (agreed ?? false) {
await ref.read(warpOptionNotifierProvider.notifier).agree();
await onChange(ConfigOptionPatch(enableWarp: value));
}
} else {
await onChange(ConfigOptionPatch(enableWarp: value));
}
},
),
ListTile(
title: Text(t.settings.config.warpDetourMode),
subtitle: Text(options.warpDetourMode.present(t)),
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),
);
},
),
ListTile(
title: Text(t.settings.config.warpLicenseKey),
subtitle: Text(
options.warpLicenseKey.isEmpty
? t.general.notSet
: options.warpLicenseKey,
),
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));
},
),
ListTile(
title: Text(t.settings.config.warpCleanIp),
subtitle: Text(options.warpCleanIp),
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));
},
),
ListTile(
title: Text(t.settings.config.warpPort),
subtitle: Text(options.warpPort.toString()),
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),
);
},
),
ListTile(
title: Text(t.settings.config.warpNoise),
subtitle: Text(options.warpNoise.present(t)),
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: RangeWithOptionalCeil.tryParse(
warpNoise,
allowEmpty: true,
),
),
);
},
),
],
);
}
}
class WarpLicenseAgreementModal extends HookConsumerWidget {
const WarpLicenseAgreementModal({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(translationsProvider);
return AlertDialog.adaptive(
title: Text(t.settings.config.warpConsent.title),
content: Text.rich(
t.settings.config.warpConsent.description(
tos: (text) => TextSpan(
text: text,
style: const TextStyle(color: Colors.blue),
recognizer: TapGestureRecognizer()
..onTap = () async {
await UriUtils.tryLaunch(
Uri.parse(Constants.cfWarpTermsOfService),
);
},
),
privacy: (text) => TextSpan(
text: text,
style: const TextStyle(color: Colors.blue),
recognizer: TapGestureRecognizer()
..onTap = () async {
await UriUtils.tryLaunch(
Uri.parse(Constants.cfWarpPrivacyPolicy),
);
},
),
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(false);
},
child: Text(t.general.decline),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: Text(t.general.agree),
),
],
);
}
}

View File

@@ -78,3 +78,13 @@ enum MuxProtocol {
smux,
yamux;
}
enum WarpDetourMode {
outbound,
inbound;
String present(TranslationsEn t) => switch (this) {
outbound => t.settings.config.warpDetourModes.outbound,
inbound => t.settings.config.warpDetourModes.inbound,
};
}

View File

@@ -52,6 +52,13 @@ class SingboxConfigOption with _$SingboxConfigOption {
required bool muxPadding,
required int muxMaxStreams,
required MuxProtocol muxProtocol,
required bool enableWarp,
required WarpDetourMode warpDetourMode,
required String warpLicenseKey,
required String warpCleanIp,
required int warpPort,
@RangeWithOptionalCeilJsonConverter()
required RangeWithOptionalCeil warpNoise,
required String geoipPath,
required String geositePath,
required List<SingboxRule> rules,