From 79e89c2ec252810c1b585f2f29f973718554dba6 Mon Sep 17 00:00:00 2001 From: Tien Do Nam <38380847+Tienisto@users.noreply.github.com> Date: Sat, 28 Dec 2024 02:55:37 +0100 Subject: [PATCH] feat: add advanced setting to filter network interfaces (#2167) --- app/assets/CHANGELOG.md | 1 + app/assets/i18n/en.json | 12 + app/lib/config/init.dart | 2 + app/lib/gen/strings.g.dart | 2 +- app/lib/gen/strings_en.g.dart | 29 +++ app/lib/model/state/settings_state.dart | 4 + .../model/state/settings_state.mapper.dart | 26 ++ .../settings/network_interfaces_page.dart | 223 ++++++++++++++++++ app/lib/pages/tabs/settings_tab.dart | 12 + .../pages/tabs/settings_tab_controller.dart | 14 ++ app/lib/provider/local_ip_provider.dart | 47 ++-- app/lib/provider/persistence_provider.dart | 26 ++ app/lib/provider/settings_provider.dart | 26 +- app/lib/widget/dialogs/text_field_tv.dart | 16 +- app/pubspec.lock | 16 ++ app/pubspec.yaml | 6 +- app/test/mocks.mocks.dart | 20 ++ cli/pubspec.lock | 18 +- .../child/multicast_discovery_isolate.dart | 22 +- .../lib/src/isolate/child/sync_provider.dart | 6 +- .../isolate/child/sync_provider.mapper.dart | 26 ++ common/lib/src/isolate/parent/actions.dart | 19 +- .../lib/src/isolate/parent/actions_sync.dart | 6 + .../parent/parent_isolate_provider.dart | 4 +- .../parent_isolate_provider.mapper.dart | 6 +- .../task/discovery/multicast_discovery.dart | 107 ++++++--- common/lib/util/network_interfaces.dart | 68 ++++++ .../unit/util/network_interfaces_test.dart | 49 ++++ 28 files changed, 728 insertions(+), 85 deletions(-) create mode 100644 app/lib/pages/settings/network_interfaces_page.dart create mode 100644 common/lib/util/network_interfaces.dart create mode 100644 common/test/unit/util/network_interfaces_test.dart diff --git a/app/assets/CHANGELOG.md b/app/assets/CHANGELOG.md index 1ebfa817..6082c983 100644 --- a/app/assets/CHANGELOG.md +++ b/app/assets/CHANGELOG.md @@ -1,5 +1,6 @@ ## 1.17.0 (unreleased) +- feat: add advanced setting to filter network interfaces (@Tienisto) - feat(windows): when pasting an image, automatically convert it to PNG (@BrianMwit) - feat(android): add option to open gallery when image/video was automatically saved (@Tienisto) - fix: black screen when tapping on "Back" twice in "Share via link" (@Tienisto) diff --git a/app/assets/i18n/en.json b/app/assets/i18n/en.json index b1f4a898..f7f10bdc 100644 --- a/app/assets/i18n/en.json +++ b/app/assets/i18n/en.json @@ -134,6 +134,11 @@ "deviceType": "Device type", "deviceModel": "Device model", "port": "Port", + "network": "Network", + "networkOptions": { + "all": "All", + "filtered": "Filtered" + }, "discoveryTimeout": "Discovery Timeout", "useSystemName": "Use system name", "generateRandomAlias": "Generate random alias", @@ -170,6 +175,13 @@ "solution": "Does the problem exist on both sides? If so, you need to make sure that both devices are on the same Wi-Fi network and share the same configuration (port, multicast address, encryption). The Wi-Fi network may not allow communication between participants due to Access Point (AP) Isolation. In this case, this option must be disabled on the router." } }, + "networkInterfacesPage": { + "title": "Network Interfaces", + "info": "By default, LocalSend uses all available network interfaces. You can exclude unwanted networks here. You need to restart the server to apply the changes.", + "preview": "Preview", + "whitelist": "Whitelist", + "blacklist": "Blacklist" + }, "receiveHistoryPage": { "title": "History", "openFolder": "Open folder", diff --git a/app/lib/config/init.dart b/app/lib/config/init.dart index d2b68299..c83ccbfa 100644 --- a/app/lib/config/init.dart +++ b/app/lib/config/init.dart @@ -156,6 +156,8 @@ Future preInit(List args) async { deviceInfo: ref.read(deviceInfoProvider), alias: settings.alias, port: settings.port, + networkWhitelist: settings.networkWhitelist, + networkBlacklist: settings.networkBlacklist, protocol: settings.https ? ProtocolType.https : ProtocolType.http, multicastGroup: settings.multicastGroup, discoveryTimeout: settings.discoveryTimeout, diff --git a/app/lib/gen/strings.g.dart b/app/lib/gen/strings.g.dart index a4afed8b..99c82324 100644 --- a/app/lib/gen/strings.g.dart +++ b/app/lib/gen/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 52 -/// Strings: 16374 (314 per locale) +/// Strings: 16382 (315 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/app/lib/gen/strings_en.g.dart b/app/lib/gen/strings_en.g.dart index 5e100e46..a5a8c6bc 100644 --- a/app/lib/gen/strings_en.g.dart +++ b/app/lib/gen/strings_en.g.dart @@ -41,6 +41,7 @@ class Translations implements BaseTranslations { late final TranslationsSendTabEn sendTab = TranslationsSendTabEn.internal(_root); late final TranslationsSettingsTabEn settingsTab = TranslationsSettingsTabEn.internal(_root); late final TranslationsTroubleshootPageEn troubleshootPage = TranslationsTroubleshootPageEn.internal(_root); + late final TranslationsNetworkInterfacesPageEn networkInterfacesPage = TranslationsNetworkInterfacesPageEn.internal(_root); late final TranslationsReceiveHistoryPageEn receiveHistoryPage = TranslationsReceiveHistoryPageEn.internal(_root); late final TranslationsApkPickerPageEn apkPickerPage = TranslationsApkPickerPageEn.internal(_root); late final TranslationsSelectedFilesPageEn selectedFilesPage = TranslationsSelectedFilesPageEn.internal(_root); @@ -173,6 +174,21 @@ class TranslationsTroubleshootPageEn { late final TranslationsTroubleshootPageNoConnectionEn noConnection = TranslationsTroubleshootPageNoConnectionEn.internal(_root); } +// Path: networkInterfacesPage +class TranslationsNetworkInterfacesPageEn { + TranslationsNetworkInterfacesPageEn.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get title => 'Network Interfaces'; + String get info => + 'By default, LocalSend uses all available network interfaces. You can exclude unwanted networks here. You need to restart the server to apply the changes.'; + String get preview => 'Preview'; + String get whitelist => 'Whitelist'; + String get blacklist => 'Blacklist'; +} + // Path: receiveHistoryPage class TranslationsReceiveHistoryPageEn { TranslationsReceiveHistoryPageEn.internal(this._root); @@ -657,6 +673,8 @@ class TranslationsSettingsTabNetworkEn { String get deviceType => 'Device type'; String get deviceModel => 'Device model'; String get port => 'Port'; + String get network => 'Network'; + late final TranslationsSettingsTabNetworkNetworkOptionsEn networkOptions = TranslationsSettingsTabNetworkNetworkOptionsEn.internal(_root); String get discoveryTimeout => 'Discovery Timeout'; String get useSystemName => 'Use system name'; String get generateRandomAlias => 'Generate random alias'; @@ -1072,6 +1090,17 @@ class TranslationsSettingsTabGeneralLanguageOptionsEn { String get system => 'System'; } +// Path: settingsTab.network.networkOptions +class TranslationsSettingsTabNetworkNetworkOptionsEn { + TranslationsSettingsTabNetworkNetworkOptionsEn.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get all => 'All'; + String get filtered => 'Filtered'; +} + // Path: progressPage.total.title class TranslationsProgressPageTotalTitleEn { TranslationsProgressPageTotalTitleEn.internal(this._root); diff --git a/app/lib/model/state/settings_state.dart b/app/lib/model/state/settings_state.dart index 85898301..ca5d9cf7 100644 --- a/app/lib/model/state/settings_state.dart +++ b/app/lib/model/state/settings_state.dart @@ -15,6 +15,8 @@ class SettingsState with SettingsStateMappable { final ColorMode colorMode; final AppLocale? locale; final int port; + final List? networkWhitelist; // null = disabled + final List? networkBlacklist; // null = disabled final String multicastGroup; final String? destination; // null = default final bool saveToGallery; // only Android, iOS @@ -41,6 +43,8 @@ class SettingsState with SettingsStateMappable { required this.colorMode, required this.locale, required this.port, + required this.networkWhitelist, + required this.networkBlacklist, required this.multicastGroup, required this.destination, required this.saveToGallery, diff --git a/app/lib/model/state/settings_state.mapper.dart b/app/lib/model/state/settings_state.mapper.dart index 88ae24d8..7d31759b 100644 --- a/app/lib/model/state/settings_state.mapper.dart +++ b/app/lib/model/state/settings_state.mapper.dart @@ -33,6 +33,10 @@ class SettingsStateMapper extends ClassMapperBase { static const Field _f$locale = Field('locale', _$locale); static int _$port(SettingsState v) => v.port; static const Field _f$port = Field('port', _$port); + static List? _$networkWhitelist(SettingsState v) => v.networkWhitelist; + static const Field> _f$networkWhitelist = Field('networkWhitelist', _$networkWhitelist); + static List? _$networkBlacklist(SettingsState v) => v.networkBlacklist; + static const Field> _f$networkBlacklist = Field('networkBlacklist', _$networkBlacklist); static String _$multicastGroup(SettingsState v) => v.multicastGroup; static const Field _f$multicastGroup = Field('multicastGroup', _$multicastGroup); static String? _$destination(SettingsState v) => v.destination; @@ -78,6 +82,8 @@ class SettingsStateMapper extends ClassMapperBase { #colorMode: _f$colorMode, #locale: _f$locale, #port: _f$port, + #networkWhitelist: _f$networkWhitelist, + #networkBlacklist: _f$networkBlacklist, #multicastGroup: _f$multicastGroup, #destination: _f$destination, #saveToGallery: _f$saveToGallery, @@ -106,6 +112,8 @@ class SettingsStateMapper extends ClassMapperBase { colorMode: data.dec(_f$colorMode), locale: data.dec(_f$locale), port: data.dec(_f$port), + networkWhitelist: data.dec(_f$networkWhitelist), + networkBlacklist: data.dec(_f$networkBlacklist), multicastGroup: data.dec(_f$multicastGroup), destination: data.dec(_f$destination), saveToGallery: data.dec(_f$saveToGallery), @@ -170,6 +178,8 @@ extension SettingsStateValueCopy<$R, $Out> on ObjectCopyWith<$R, SettingsState, } abstract class SettingsStateCopyWith<$R, $In extends SettingsState, $Out> implements ClassCopyWith<$R, $In, $Out> { + ListCopyWith<$R, String, ObjectCopyWith<$R, String, String>>? get networkWhitelist; + ListCopyWith<$R, String, ObjectCopyWith<$R, String, String>>? get networkBlacklist; $R call( {String? showToken, String? alias, @@ -177,6 +187,8 @@ abstract class SettingsStateCopyWith<$R, $In extends SettingsState, $Out> implem ColorMode? colorMode, AppLocale? locale, int? port, + List? networkWhitelist, + List? networkBlacklist, String? multicastGroup, String? destination, bool? saveToGallery, @@ -205,6 +217,14 @@ class _SettingsStateCopyWithImpl<$R, $Out> extends ClassCopyWithBase<$R, Setting @override late final ClassMapperBase $mapper = SettingsStateMapper.ensureInitialized(); @override + ListCopyWith<$R, String, ObjectCopyWith<$R, String, String>>? get networkWhitelist => $value.networkWhitelist != null + ? ListCopyWith($value.networkWhitelist!, (v, t) => ObjectCopyWith(v, $identity, t), (v) => call(networkWhitelist: v)) + : null; + @override + ListCopyWith<$R, String, ObjectCopyWith<$R, String, String>>? get networkBlacklist => $value.networkBlacklist != null + ? ListCopyWith($value.networkBlacklist!, (v, t) => ObjectCopyWith(v, $identity, t), (v) => call(networkBlacklist: v)) + : null; + @override $R call( {String? showToken, String? alias, @@ -212,6 +232,8 @@ class _SettingsStateCopyWithImpl<$R, $Out> extends ClassCopyWithBase<$R, Setting ColorMode? colorMode, Object? locale = $none, int? port, + Object? networkWhitelist = $none, + Object? networkBlacklist = $none, String? multicastGroup, Object? destination = $none, bool? saveToGallery, @@ -237,6 +259,8 @@ class _SettingsStateCopyWithImpl<$R, $Out> extends ClassCopyWithBase<$R, Setting if (colorMode != null) #colorMode: colorMode, if (locale != $none) #locale: locale, if (port != null) #port: port, + if (networkWhitelist != $none) #networkWhitelist: networkWhitelist, + if (networkBlacklist != $none) #networkBlacklist: networkBlacklist, if (multicastGroup != null) #multicastGroup: multicastGroup, if (destination != $none) #destination: destination, if (saveToGallery != null) #saveToGallery: saveToGallery, @@ -264,6 +288,8 @@ class _SettingsStateCopyWithImpl<$R, $Out> extends ClassCopyWithBase<$R, Setting colorMode: data.get(#colorMode, or: $value.colorMode), locale: data.get(#locale, or: $value.locale), port: data.get(#port, or: $value.port), + networkWhitelist: data.get(#networkWhitelist, or: $value.networkWhitelist), + networkBlacklist: data.get(#networkBlacklist, or: $value.networkBlacklist), multicastGroup: data.get(#multicastGroup, or: $value.multicastGroup), destination: data.get(#destination, or: $value.destination), saveToGallery: data.get(#saveToGallery, or: $value.saveToGallery), diff --git a/app/lib/pages/settings/network_interfaces_page.dart b/app/lib/pages/settings/network_interfaces_page.dart new file mode 100644 index 00000000..d0638aba --- /dev/null +++ b/app/lib/pages/settings/network_interfaces_page.dart @@ -0,0 +1,223 @@ +import 'package:collection/collection.dart'; +import 'package:common/util/network_interfaces.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:local_hero/local_hero.dart'; +import 'package:localsend_app/gen/strings.g.dart'; +import 'package:localsend_app/provider/settings_provider.dart'; +import 'package:localsend_app/widget/dialogs/text_field_tv.dart'; +import 'package:localsend_app/widget/labeled_checkbox.dart'; +import 'package:localsend_app/widget/responsive_list_view.dart'; +import 'package:moform/moform.dart'; +import 'package:refena_flutter/refena_flutter.dart'; + +class NetworkInterfacesPage extends StatefulWidget { + const NetworkInterfacesPage(); + + @override + State createState() => _NetworkInterfacesPageState(); +} + +class _NetworkInterfacesPageState extends State { + List<(String, List)> rawInterfaces = []; + + @override + void initState() { + super.initState(); + + // ignore: discarded_futures + getNetworkInterfaces(whitelist: null, blacklist: null).then((value) { + if (mounted) { + setState(() { + rawInterfaces = value.map((e) => (e.name, e.addresses.map((a) => a.address).toList())).toList(); + }); + } + }); + } + + @override + Widget build(BuildContext context) { + final settings = context.watch(settingsProvider); + final currList = settings.networkWhitelist ?? settings.networkBlacklist ?? []; + final Future Function(List?) updateFunction = settings.networkWhitelist != null + ? context.notifier(settingsProvider).setNetworkWhitelist + : context.notifier(settingsProvider).setNetworkBlacklist; + return Scaffold( + appBar: AppBar( + title: Text(t.networkInterfacesPage.title), + ), + body: LocalHeroScope( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: ResponsiveListView( + padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 20), + children: [ + Text( + t.networkInterfacesPage.info, + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.only(left: 10), + child: Text( + t.networkInterfacesPage.preview, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ), + ScrollConfiguration( + // By default, Flutter only allows dragging with touch devices. + // We also allow dragging with mouse. + behavior: const MaterialScrollBehavior().copyWith( + dragDevices: { + PointerDeviceKind.mouse, + PointerDeviceKind.touch, + PointerDeviceKind.stylus, + PointerDeviceKind.trackpad, + PointerDeviceKind.unknown, + }, + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: rawInterfaces.mapIndexed((i, e) { + final ignored = isNetworkIgnoredRaw( + networkWhitelist: settings.networkWhitelist, + networkBlacklist: settings.networkBlacklist, + interface: e.$2, + ); + final style = ignored + ? const TextStyle( + color: Colors.grey, + decoration: TextDecoration.lineThrough, + ) + : null; + return Padding( + padding: const EdgeInsets.only(right: 10), + child: Card( + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('[#${i + 1}] ${e.$1}', style: style), + ...e.$2.map((ip) => Text(ip, style: style)), + ], + ), + ), + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + LabeledCheckbox( + label: t.networkInterfacesPage.whitelist, + value: settings.networkWhitelist != null, + onChanged: (value) async { + if (value == false) { + await context.notifier(settingsProvider).setNetworkWhitelist(null); + } else { + await context.notifier(settingsProvider).setNetworkWhitelist(switch (currList) { + [] => [''], + _ => [...currList], + }); + if (context.mounted) { + await context.notifier(settingsProvider).setNetworkBlacklist(null); + } + } + }, + ), + LabeledCheckbox( + label: t.networkInterfacesPage.blacklist, + value: settings.networkBlacklist != null, + onChanged: (value) async { + if (value == false) { + await context.notifier(settingsProvider).setNetworkBlacklist(null); + } else { + await context.notifier(settingsProvider).setNetworkBlacklist(switch (currList) { + [] => [''], + _ => [...currList], + }); + if (context.mounted) { + await context.notifier(settingsProvider).setNetworkWhitelist(null); + } + } + }, + ), + ], + ), + const SizedBox(height: 20), + ...currList.mapIndexed((i, e) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: StringField( + value: e, + onChanged: (value) async { + await updateFunction([ + ...currList.sublist(0, i), + value, + ...currList.sublist(i + 1), + ]); + }, + builder: (context, controller) { + return TextFieldTv( + name: t.networkInterfacesPage.whitelist, + controller: controller, + onDelete: () async { + if (currList.length == 1) { + await updateFunction(null); + return; + } + await updateFunction([ + ...currList.sublist(0, i), + ...currList.sublist(i + 1), + ]); + }, + ); + }), + ); + }), + if (settings.networkWhitelist != null || settings.networkBlacklist != null) + LocalHero( + tag: 'network_interfaces_bottom', + child: Row( + children: [ + Material( + color: Colors.transparent, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${t.general.example}:', + ), + Text('123.123.123.123'), + Text('123.123.123.*'), + ], + ), + ), + const Spacer(), + FilledButton.icon( + onPressed: () async { + await updateFunction([ + ...currList, + '', + ]); + }, + icon: const Icon(Icons.add), + label: Text(t.general.add), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/pages/tabs/settings_tab.dart b/app/lib/pages/tabs/settings_tab.dart index 039a9887..6ca7f250 100644 --- a/app/lib/pages/tabs/settings_tab.dart +++ b/app/lib/pages/tabs/settings_tab.dart @@ -10,6 +10,7 @@ import 'package:localsend_app/pages/about/about_page.dart'; import 'package:localsend_app/pages/changelog_page.dart'; import 'package:localsend_app/pages/donation/donation_page.dart'; import 'package:localsend_app/pages/language_page.dart'; +import 'package:localsend_app/pages/settings/network_interfaces_page.dart'; import 'package:localsend_app/pages/tabs/settings_tab_controller.dart'; import 'package:localsend_app/provider/settings_provider.dart'; import 'package:localsend_app/provider/version_provider.dart'; @@ -401,6 +402,17 @@ class SettingsTab extends StatelessWidget { }, ), ), + if (vm.advanced) + _ButtonEntry( + label: t.settingsTab.network.network, + buttonLabel: switch (vm.settings.networkWhitelist != null || vm.settings.networkBlacklist != null) { + true => t.settingsTab.network.networkOptions.filtered, + false => t.settingsTab.network.networkOptions.all, + }, + onTap: () async { + await context.push(() => const NetworkInterfacesPage()); + }, + ), if (vm.advanced) _SettingsEntry( label: t.settingsTab.network.discoveryTimeout, diff --git a/app/lib/pages/tabs/settings_tab_controller.dart b/app/lib/pages/tabs/settings_tab_controller.dart index 3feb7f0a..57df25c3 100644 --- a/app/lib/pages/tabs/settings_tab_controller.dart +++ b/app/lib/pages/tabs/settings_tab_controller.dart @@ -1,3 +1,4 @@ +import 'package:common/isolate.dart'; import 'package:common/model/device_info_result.dart'; import 'package:common/util/sleep.dart'; import 'package:flutter/material.dart'; @@ -6,6 +7,7 @@ import 'package:localsend_app/model/persistence/color_mode.dart'; import 'package:localsend_app/pages/language_page.dart'; import 'package:localsend_app/pages/tabs/settings_tab_vm.dart'; import 'package:localsend_app/provider/device_info_provider.dart'; +import 'package:localsend_app/provider/local_ip_provider.dart'; import 'package:localsend_app/provider/network/server/server_provider.dart'; import 'package:localsend_app/provider/settings_provider.dart'; import 'package:localsend_app/util/native/autostart_helper.dart'; @@ -18,12 +20,16 @@ import 'package:routerino/routerino.dart'; final settingsTabControllerProvider = ReduxProvider((ref) { final settings = ref.notifier(settingsProvider); final server = ref.notifier(serverProvider); + final isolateController = ref.notifier(parentIsolateProvider); + final localIpService = ref.notifier(localIpProvider); final initialDeviceInfo = ref.read(deviceInfoProvider); final supportsDynamicColors = ref.read(dynamicColorsProvider) != null; return SettingsTabController( settingsService: settings, serverNotifier: server, + isolateController: isolateController, + localIpService: localIpService, initialDeviceInfo: initialDeviceInfo, supportsDynamicColors: supportsDynamicColors, ); @@ -32,16 +38,22 @@ final settingsTabControllerProvider = ReduxProvider { final SettingsService _settingsService; final ServerService _serverService; + final IsolateController _isolateController; + final LocalIpService _localIpService; final DeviceInfoResult _initialDeviceInfo; final bool _supportsDynamicColors; SettingsTabController({ required SettingsService settingsService, required ServerService serverNotifier, + required IsolateController isolateController, + required LocalIpService localIpService, required DeviceInfoResult initialDeviceInfo, required bool supportsDynamicColors, }) : _settingsService = settingsService, _serverService = serverNotifier, + _isolateController = isolateController, + _localIpService = localIpService, _initialDeviceInfo = initialDeviceInfo, _supportsDynamicColors = supportsDynamicColors; @@ -123,6 +135,8 @@ class SettingsTabController extends ReduxNotifier { state.portController.text = newServerState.port.toString(); await _settingsService.setAlias(newServerState.alias); await _settingsService.setPort(newServerState.port); + external(_isolateController).dispatch(IsolateSendMulticastRestartListenerAction()); + external(_localIpService).dispatchAsync(FetchLocalIpAction()); // ignore: unawaited_futures } } catch (e) { // ignore: use_build_context_synchronously diff --git a/app/lib/provider/local_ip_provider.dart b/app/lib/provider/local_ip_provider.dart index 6bc25f5a..97c43b31 100644 --- a/app/lib/provider/local_ip_provider.dart +++ b/app/lib/provider/local_ip_provider.dart @@ -1,10 +1,11 @@ import 'dart:async'; -import 'dart:io'; import 'package:collection/collection.dart'; +import 'package:common/util/network_interfaces.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:localsend_app/model/state/network_state.dart'; +import 'package:localsend_app/provider/settings_provider.dart'; import 'package:localsend_app/util/native/platform_check.dart'; import 'package:logging/logging.dart'; import 'package:network_info_plus/network_info_plus.dart' as plugin; @@ -13,13 +14,17 @@ import 'package:refena_flutter/refena_flutter.dart'; final _logger = Logger('NetworkInfo'); final localIpProvider = ReduxProvider((ref) { - return LocalIpService(); + return LocalIpService( + ref.notifier(settingsProvider), + ); }); StreamSubscription? _subscription; class LocalIpService extends ReduxNotifier { - LocalIpService(); + final SettingsService _settingsService; + + LocalIpService(this._settingsService); @override NetworkState init() { @@ -44,11 +49,11 @@ class InitLocalIpAction extends ReduxAction { if (checkPlatform([TargetPlatform.windows])) { // https://github.com/localsend/localsend/issues/12 _subscription = Stream.periodic(const Duration(seconds: 5), (_) {}).listen((_) async { - await dispatchAsync(_FetchLocalIpAction()); + await dispatchAsync(FetchLocalIpAction()); }); } else { _subscription = Connectivity().onConnectivityChanged.listen((_) async { - await dispatchAsync(_FetchLocalIpAction()); + await dispatchAsync(FetchLocalIpAction()); }); } } @@ -59,21 +64,27 @@ class InitLocalIpAction extends ReduxAction { @override void after() { // ignore: discarded_futures - dispatchAsync(_FetchLocalIpAction()); + dispatchAsync(FetchLocalIpAction()); } } -class _FetchLocalIpAction extends AsyncReduxAction { +class FetchLocalIpAction extends AsyncReduxAction { @override Future reduce() async { return NetworkState( - localIps: await _getIp(), + localIps: await _getIp( + whitelist: notifier._settingsService.state.networkWhitelist, + blacklist: notifier._settingsService.state.networkBlacklist, + ), initialized: true, ); } } -Future> _getIp() async { +Future> _getIp({ + required List? whitelist, + required List? blacklist, +}) async { final info = plugin.NetworkInfo(); String? ip; try { @@ -82,16 +93,14 @@ Future> _getIp() async { _logger.warning('Failed to get wifi IP', e); } - List nativeResult = []; - if (!kIsWeb) { - try { - // fallback with dart:io NetworkInterface - final result = (await NetworkInterface.list()).map((networkInterface) => networkInterface.addresses).expand((ip) => ip); - nativeResult = result.where((ip) => ip.type == InternetAddressType.IPv4).map((address) => address.address).toList(); - } catch (e, st) { - _logger.info('Failed to get IP from dart:io', e, st); - } - } + final nativeResult = (await getNetworkInterfaces( + whitelist: whitelist, + blacklist: blacklist, + )) + .map((interface) => interface.addresses.map((a) => a.address).toList()) + .expand((ip) => ip) + .where((ip) => !ip.contains(':')) // ignore IPv6 for now + .toList(); final addresses = rankIpAddresses(nativeResult, ip); _logger.info('Network state: $addresses'); diff --git a/app/lib/provider/persistence_provider.dart b/app/lib/provider/persistence_provider.dart index cfd9ecf0..05e2e7f4 100644 --- a/app/lib/provider/persistence_provider.dart +++ b/app/lib/provider/persistence_provider.dart @@ -66,6 +66,8 @@ const _themeKey = 'ls_theme'; // now called brightness const _colorKey = 'ls_color'; const _localeKey = 'ls_locale'; const _portKey = 'ls_port'; +const _networkWhitelistKey = 'ls_network_whitelist'; +const _networkBlacklistKey = 'ls_network_blacklist'; const _timeoutKey = 'ls_timeout'; const _multicastGroupKey = 'ls_multicast_group'; const _destinationKey = 'ls_destination'; @@ -286,6 +288,30 @@ class PersistenceService { await _prefs.setInt(_portKey, port); } + List? getNetworkWhitelist() { + return _prefs.getStringList(_networkWhitelistKey); + } + + Future setNetworkWhitelist(List? whitelist) async { + if (whitelist == null) { + await _prefs.remove(_networkWhitelistKey); + } else { + await _prefs.setStringList(_networkWhitelistKey, whitelist); + } + } + + List? getNetworkBlacklist() { + return _prefs.getStringList(_networkBlacklistKey); + } + + Future setNetworkBlacklist(List? blacklist) async { + if (blacklist == null) { + await _prefs.remove(_networkBlacklistKey); + } else { + await _prefs.setStringList(_networkBlacklistKey, blacklist); + } + } + int getDiscoveryTimeout() { return _prefs.getInt(_timeoutKey) ?? defaultDiscoveryTimeout; } diff --git a/app/lib/provider/settings_provider.dart b/app/lib/provider/settings_provider.dart index a3ddabe6..35c94953 100644 --- a/app/lib/provider/settings_provider.dart +++ b/app/lib/provider/settings_provider.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:common/isolate.dart'; import 'package:common/model/device.dart'; import 'package:flutter/material.dart'; @@ -8,15 +9,22 @@ import 'package:localsend_app/model/state/settings_state.dart'; import 'package:localsend_app/provider/persistence_provider.dart'; import 'package:refena_flutter/refena_flutter.dart'; +final _listEq = const ListEquality().equals; + final settingsProvider = NotifierProvider((ref) { return SettingsService(ref.read(persistenceProvider)); }, onChanged: (_, next, ref) { final syncState = ref.read(parentIsolateProvider).syncState; - if (syncState.multicastGroup == next.multicastGroup && syncState.discoveryTimeout == next.discoveryTimeout) { + if (_listEq(syncState.networkWhitelist, next.networkWhitelist) && + _listEq(syncState.networkBlacklist, next.networkBlacklist) && + syncState.multicastGroup == next.multicastGroup && + syncState.discoveryTimeout == next.discoveryTimeout) { return; } ref.redux(parentIsolateProvider).dispatch(IsolateSyncSettingsAction( + networkWhitelist: next.networkWhitelist, + networkBlacklist: next.networkBlacklist, multicastGroup: next.multicastGroup, discoveryTimeout: next.discoveryTimeout, )); @@ -35,6 +43,8 @@ class SettingsService extends PureNotifier { colorMode: _persistence.getColorMode(), locale: _persistence.getLocale(), port: _persistence.getPort(), + networkWhitelist: _persistence.getNetworkWhitelist(), + networkBlacklist: _persistence.getNetworkBlacklist(), multicastGroup: _persistence.getMulticastGroup(), destination: _persistence.getDestination(), saveToGallery: _persistence.isSaveToGallery(), @@ -97,6 +107,20 @@ class SettingsService extends PureNotifier { ); } + Future setNetworkWhitelist(List? whitelist) async { + await _persistence.setNetworkWhitelist(whitelist); + state = state.copyWith( + networkWhitelist: whitelist, + ); + } + + Future setNetworkBlacklist(List? blacklist) async { + await _persistence.setNetworkBlacklist(blacklist); + state = state.copyWith( + networkBlacklist: blacklist, + ); + } + Future setDiscoveryTimeout(int timeout) async { await _persistence.setDiscoveryTimeout(timeout); state = state.copyWith( diff --git a/app/lib/widget/dialogs/text_field_tv.dart b/app/lib/widget/dialogs/text_field_tv.dart index 2c6b4a0a..6b285512 100644 --- a/app/lib/widget/dialogs/text_field_tv.dart +++ b/app/lib/widget/dialogs/text_field_tv.dart @@ -10,12 +10,14 @@ import 'package:routerino/routerino.dart'; class TextFieldTv extends StatefulWidget { final String name; final TextEditingController controller; - final ValueChanged onChanged; + final ValueChanged? onChanged; + final VoidCallback? onDelete; const TextFieldTv({ required this.name, required this.controller, - required this.onChanged, + this.onChanged, + this.onDelete, }); @override @@ -71,6 +73,16 @@ class _TextFieldTvState extends State with Refena { controller: widget.controller, textAlign: TextAlign.center, onChanged: widget.onChanged, + decoration: InputDecoration( + suffixIcon: widget.onDelete != null + ? IconButton( + icon: Icon(Icons.clear), + onPressed: () { + widget.onDelete?.call(); + }, + ) + : null, + ), ); } } diff --git a/app/pubspec.lock b/app/pubspec.lock index 7e777fba..fdc51e15 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -857,6 +857,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + local_hero: + dependency: "direct main" + description: + name: local_hero + sha256: "5c85451dd51ecd0e8d3656775fac9a6db82f296f200d9931217186d34fed6089" + url: "https://pub.dev" + source: hosted + version: "0.3.0" logging: dependency: "direct main" description: @@ -937,6 +945,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.4.4" + moform: + dependency: "direct main" + description: + name: moform + sha256: "4ef955b2422b0c7c676128b398e417b191647b6a1259d3928000a9a40c63f571" + url: "https://pub.dev" + source: hosted + version: "0.2.5" nanoid2: dependency: "direct main" description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 4c6b01ce..539ae0dc 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -29,15 +29,17 @@ dependencies: sdk: flutter flutter_markdown: 0.7.4+2 gal: 2.3.0 - image: ^4.3.0 + image: 4.3.0 image_picker: 1.1.2 in_app_purchase: 3.2.0 # [FOSS_REMOVE] intl: ^0.19.0 # allow newer versions, so it can compile with newer Flutter versions - legalize: ^1.2.2 + legalize: 1.2.2 + local_hero: 0.3.0 logging: 1.3.0 # https://github.com/NightFeather0615/macos_dock_progress/issues/1 # macos_dock_progress: 1.1.0 mime: 1.0.6 + moform: 0.2.5 nanoid2: 2.0.1 network_info_plus: 6.1.1 open_dir: 0.0.2+1 diff --git a/app/test/mocks.mocks.dart b/app/test/mocks.mocks.dart index b793a771..ba071171 100644 --- a/app/test/mocks.mocks.dart +++ b/app/test/mocks.mocks.dart @@ -258,6 +258,26 @@ class MockPersistenceService extends _i1.Mock implements _i3.PersistenceService returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); + @override + _i4.Future setNetworkWhitelist(List? whitelist) => (super.noSuchMethod( + Invocation.method( + #setNetworkWhitelist, + [whitelist], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future setNetworkBlacklist(List? blacklist) => (super.noSuchMethod( + Invocation.method( + #setNetworkBlacklist, + [blacklist], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + @override int getDiscoveryTimeout() => (super.noSuchMethod( Invocation.method( diff --git a/cli/pubspec.lock b/cli/pubspec.lock index 493abda4..687d3c80 100644 --- a/cli/pubspec.lock +++ b/cli/pubspec.lock @@ -93,22 +93,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.2" - dio: - dependency: transitive - description: - name: dio - sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260" - url: "https://pub.dev" - source: hosted - version: "5.7.0" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8" - url: "https://pub.dev" - source: hosted - version: "2.0.0" file: dependency: transitive description: @@ -446,4 +430,4 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.4.0 <4.0.0" + dart: ">=3.5.0 <4.0.0" diff --git a/common/lib/src/isolate/child/multicast_discovery_isolate.dart b/common/lib/src/isolate/child/multicast_discovery_isolate.dart index 346324da..a3b389c3 100644 --- a/common/lib/src/isolate/child/multicast_discovery_isolate.dart +++ b/common/lib/src/isolate/child/multicast_discovery_isolate.dart @@ -4,21 +4,30 @@ import 'package:common/src/isolate/dto/send_to_isolate_data.dart'; import 'package:common/src/task/discovery/multicast_discovery.dart'; import 'package:meta/meta.dart'; +sealed class MulticastTask {} + /// Sends an announcement to all devices to all network interfaces. /// They will respond with their device information. /// /// This is not wrapped in an [IsolateTask] because /// - (1) it is not important if the device was found by this specific announcement or another one /// - (2) it is not 100% accurate to know if a device was found by this announcement or another one -class MulticastAnnouncementTask { +class MulticastAnnouncementTask implements MulticastTask { static const instance = MulticastAnnouncementTask._(); const MulticastAnnouncementTask._(); } +/// Restarts the listener. +class MulticastRestartListenerTask implements MulticastTask { + static const instance = MulticastRestartListenerTask._(); + + const MulticastRestartListenerTask._(); +} + @internal Future setupMulticastDiscoveryIsolate( - Stream> receiveFromMain, + Stream> receiveFromMain, void Function(Device) sendToMain, InitialData initialData, ) async { @@ -33,7 +42,14 @@ Future setupMulticastDiscoveryIsolate( }); }, handler: (ref, task) async { - await ref.read(multicastDiscoveryProvider).sendAnnouncement(); + switch (task) { + case MulticastAnnouncementTask(): + await ref.read(multicastDiscoveryProvider).sendAnnouncement(); + break; + case MulticastRestartListenerTask(): + ref.read(multicastDiscoveryProvider).restartListener(); + break; + } }, ); } diff --git a/common/lib/src/isolate/child/sync_provider.dart b/common/lib/src/isolate/child/sync_provider.dart index 25d29b76..def2b7df 100644 --- a/common/lib/src/isolate/child/sync_provider.dart +++ b/common/lib/src/isolate/child/sync_provider.dart @@ -19,6 +19,8 @@ class SyncState with SyncStateMappable { final DeviceInfoResult deviceInfo; final String alias; final int port; + final List? networkWhitelist; + final List? networkBlacklist; final ProtocolType protocol; final String multicastGroup; final int discoveryTimeout; @@ -34,6 +36,8 @@ class SyncState with SyncStateMappable { required this.deviceInfo, required this.alias, required this.port, + required this.networkWhitelist, + required this.networkBlacklist, required this.protocol, required this.multicastGroup, required this.discoveryTimeout, @@ -43,7 +47,7 @@ class SyncState with SyncStateMappable { @override String toString() { - return 'SyncState(securityContext: , deviceInfo: $deviceInfo, alias: $alias, port: $port, protocol: $protocol, multicastGroup: $multicastGroup, discoveryTimeout: $discoveryTimeout, serverRunning: $serverRunning, download: $download)'; + return 'SyncState(securityContext: , deviceInfo: $deviceInfo, alias: $alias, port: $port, networkWhitelist: $networkWhitelist, networkBlacklist: $networkBlacklist, protocol: $protocol, multicastGroup: $multicastGroup, discoveryTimeout: $discoveryTimeout, serverRunning: $serverRunning, download: $download)'; } } diff --git a/common/lib/src/isolate/child/sync_provider.mapper.dart b/common/lib/src/isolate/child/sync_provider.mapper.dart index 21b4d01e..719d9aba 100644 --- a/common/lib/src/isolate/child/sync_provider.mapper.dart +++ b/common/lib/src/isolate/child/sync_provider.mapper.dart @@ -38,6 +38,10 @@ class SyncStateMapper extends ClassMapperBase { static const Field _f$alias = Field('alias', _$alias); static int _$port(SyncState v) => v.port; static const Field _f$port = Field('port', _$port); + static List? _$networkWhitelist(SyncState v) => v.networkWhitelist; + static const Field> _f$networkWhitelist = Field('networkWhitelist', _$networkWhitelist); + static List? _$networkBlacklist(SyncState v) => v.networkBlacklist; + static const Field> _f$networkBlacklist = Field('networkBlacklist', _$networkBlacklist); static ProtocolType _$protocol(SyncState v) => v.protocol; static const Field _f$protocol = Field('protocol', _$protocol); static String _$multicastGroup(SyncState v) => v.multicastGroup; @@ -58,6 +62,8 @@ class SyncStateMapper extends ClassMapperBase { #deviceInfo: _f$deviceInfo, #alias: _f$alias, #port: _f$port, + #networkWhitelist: _f$networkWhitelist, + #networkBlacklist: _f$networkBlacklist, #protocol: _f$protocol, #multicastGroup: _f$multicastGroup, #discoveryTimeout: _f$discoveryTimeout, @@ -74,6 +80,8 @@ class SyncStateMapper extends ClassMapperBase { deviceInfo: data.dec(_f$deviceInfo), alias: data.dec(_f$alias), port: data.dec(_f$port), + networkWhitelist: data.dec(_f$networkWhitelist), + networkBlacklist: data.dec(_f$networkBlacklist), protocol: data.dec(_f$protocol), multicastGroup: data.dec(_f$multicastGroup), discoveryTimeout: data.dec(_f$discoveryTimeout), @@ -125,6 +133,8 @@ extension SyncStateValueCopy<$R, $Out> on ObjectCopyWith<$R, SyncState, $Out> { abstract class SyncStateCopyWith<$R, $In extends SyncState, $Out> implements ClassCopyWith<$R, $In, $Out> { StoredSecurityContextCopyWith<$R, StoredSecurityContext, StoredSecurityContext> get securityContext; + ListCopyWith<$R, String, ObjectCopyWith<$R, String, String>>? get networkWhitelist; + ListCopyWith<$R, String, ObjectCopyWith<$R, String, String>>? get networkBlacklist; $R call( {Future Function()? init, Object? rootIsolateToken, @@ -133,6 +143,8 @@ abstract class SyncStateCopyWith<$R, $In extends SyncState, $Out> implements Cla DeviceInfoResult? deviceInfo, String? alias, int? port, + List? networkWhitelist, + List? networkBlacklist, ProtocolType? protocol, String? multicastGroup, int? discoveryTimeout, @@ -150,6 +162,14 @@ class _SyncStateCopyWithImpl<$R, $Out> extends ClassCopyWithBase<$R, SyncState, StoredSecurityContextCopyWith<$R, StoredSecurityContext, StoredSecurityContext> get securityContext => $value.securityContext.copyWith.$chain((v) => call(securityContext: v)); @override + ListCopyWith<$R, String, ObjectCopyWith<$R, String, String>>? get networkWhitelist => $value.networkWhitelist != null + ? ListCopyWith($value.networkWhitelist!, (v, t) => ObjectCopyWith(v, $identity, t), (v) => call(networkWhitelist: v)) + : null; + @override + ListCopyWith<$R, String, ObjectCopyWith<$R, String, String>>? get networkBlacklist => $value.networkBlacklist != null + ? ListCopyWith($value.networkBlacklist!, (v, t) => ObjectCopyWith(v, $identity, t), (v) => call(networkBlacklist: v)) + : null; + @override $R call( {Future Function()? init, Object? rootIsolateToken, @@ -158,6 +178,8 @@ class _SyncStateCopyWithImpl<$R, $Out> extends ClassCopyWithBase<$R, SyncState, DeviceInfoResult? deviceInfo, String? alias, int? port, + Object? networkWhitelist = $none, + Object? networkBlacklist = $none, ProtocolType? protocol, String? multicastGroup, int? discoveryTimeout, @@ -171,6 +193,8 @@ class _SyncStateCopyWithImpl<$R, $Out> extends ClassCopyWithBase<$R, SyncState, if (deviceInfo != null) #deviceInfo: deviceInfo, if (alias != null) #alias: alias, if (port != null) #port: port, + if (networkWhitelist != $none) #networkWhitelist: networkWhitelist, + if (networkBlacklist != $none) #networkBlacklist: networkBlacklist, if (protocol != null) #protocol: protocol, if (multicastGroup != null) #multicastGroup: multicastGroup, if (discoveryTimeout != null) #discoveryTimeout: discoveryTimeout, @@ -186,6 +210,8 @@ class _SyncStateCopyWithImpl<$R, $Out> extends ClassCopyWithBase<$R, SyncState, deviceInfo: data.get(#deviceInfo, or: $value.deviceInfo), alias: data.get(#alias, or: $value.alias), port: data.get(#port, or: $value.port), + networkWhitelist: data.get(#networkWhitelist, or: $value.networkWhitelist), + networkBlacklist: data.get(#networkBlacklist, or: $value.networkBlacklist), protocol: data.get(#protocol, or: $value.protocol), multicastGroup: data.get(#multicastGroup, or: $value.multicastGroup), discoveryTimeout: data.get(#discoveryTimeout, or: $value.discoveryTimeout), diff --git a/common/lib/src/isolate/parent/actions.dart b/common/lib/src/isolate/parent/actions.dart index a542e312..a82e94e9 100644 --- a/common/lib/src/isolate/parent/actions.dart +++ b/common/lib/src/isolate/parent/actions.dart @@ -131,8 +131,6 @@ class IsolateFavoriteHttpDiscoveryAction extends ReduxActionWithResult { - IsolateSendMulticastAnnouncementAction(); - @override ParentIsolateState reduce() { final connection = state.multicastDiscovery; @@ -149,6 +147,23 @@ class IsolateSendMulticastAnnouncementAction extends ReduxAction { + @override + ParentIsolateState reduce() { + final connection = state.multicastDiscovery; + if (connection == null) { + throw StateError('multicastDiscovery is not initialized'); + } + + connection.sendToIsolate(SendToIsolateData( + syncState: null, + data: MulticastRestartListenerTask.instance, + )); + + return state; + } +} + class IsolateHttpUploadActionResult { final int taskId; final Stream progress; diff --git a/common/lib/src/isolate/parent/actions_sync.dart b/common/lib/src/isolate/parent/actions_sync.dart index 6f721618..54f173a2 100644 --- a/common/lib/src/isolate/parent/actions_sync.dart +++ b/common/lib/src/isolate/parent/actions_sync.dart @@ -45,10 +45,14 @@ class IsolateSyncDeviceInfoAction extends ReduxAction { + final List? networkWhitelist; + final List? networkBlacklist; final String multicastGroup; final int discoveryTimeout; IsolateSyncSettingsAction({ + required this.networkWhitelist, + required this.networkBlacklist, required this.multicastGroup, required this.discoveryTimeout, }); @@ -57,6 +61,8 @@ class IsolateSyncSettingsAction extends ReduxAction, SendToIsolateData>>? httpScanDiscovery; final IsolateConnector, SendToIsolateData>>? httpTargetDiscovery; - final IsolateConnector>? multicastDiscovery; + final IsolateConnector>? multicastDiscovery; final List, SendToIsolateData>>> httpUpload; int get uploadIsolateCount => httpUpload.length; @@ -94,7 +94,7 @@ class IsolateSetupAction extends AsyncReduxAction, InitialData>( + final multicastDiscovery = await startIsolate, InitialData>( task: setupMulticastDiscoveryIsolate, param: InitialData( syncState: state.syncState, diff --git a/common/lib/src/isolate/parent/parent_isolate_provider.mapper.dart b/common/lib/src/isolate/parent/parent_isolate_provider.mapper.dart index 75b5a817..652945d3 100644 --- a/common/lib/src/isolate/parent/parent_isolate_provider.mapper.dart +++ b/common/lib/src/isolate/parent/parent_isolate_provider.mapper.dart @@ -32,8 +32,8 @@ class ParentIsolateStateMapper extends ClassMapperBase { v.httpTargetDiscovery; static const Field, SendToIsolateData>>> _f$httpTargetDiscovery = Field('httpTargetDiscovery', _$httpTargetDiscovery); - static IsolateConnector>? _$multicastDiscovery(ParentIsolateState v) => v.multicastDiscovery; - static const Field>> _f$multicastDiscovery = + static IsolateConnector>? _$multicastDiscovery(ParentIsolateState v) => v.multicastDiscovery; + static const Field>> _f$multicastDiscovery = Field('multicastDiscovery', _$multicastDiscovery); static List, SendToIsolateData>>> _$httpUpload( ParentIsolateState v) => @@ -114,7 +114,7 @@ abstract class ParentIsolateStateCopyWith<$R, $In extends ParentIsolateState, $O {SyncState? syncState, IsolateConnector, SendToIsolateData>>? httpScanDiscovery, IsolateConnector, SendToIsolateData>>? httpTargetDiscovery, - IsolateConnector>? multicastDiscovery, + IsolateConnector>? multicastDiscovery, List, SendToIsolateData>>>? httpUpload}); ParentIsolateStateCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); } diff --git a/common/lib/src/task/discovery/multicast_discovery.dart b/common/lib/src/task/discovery/multicast_discovery.dart index be906cac..6660b1ef 100644 --- a/common/lib/src/task/discovery/multicast_discovery.dart +++ b/common/lib/src/task/discovery/multicast_discovery.dart @@ -9,6 +9,7 @@ import 'package:common/model/device.dart'; import 'package:common/model/dto/multicast_dto.dart'; import 'package:common/model/dto/register_dto.dart'; import 'package:common/src/isolate/child/http_provider.dart'; +import 'package:common/util/network_interfaces.dart'; import 'package:common/util/sleep.dart'; import 'package:logging/logging.dart'; import 'package:refena/refena.dart'; @@ -23,6 +24,7 @@ class MulticastService { MulticastService(this._ref); final Ref _ref; + Completer _cancelCompleter = Completer(); bool _listening = false; /// Binds the UDP port and listen to UDP multicast packages @@ -35,49 +37,78 @@ class MulticastService { _listening = true; - final streamController = StreamController(); - final syncState = _ref.read(syncProvider); + while (true) { + final streamController = StreamController(); + final syncState = _ref.read(syncProvider); - final sockets = await _getSockets(syncState.multicastGroup, syncState.port); - for (final socket in sockets) { - socket.socket.listen((_) { - final datagram = socket.socket.receive(); - if (datagram == null) { - return; - } - - try { - final dto = MulticastDto.fromJson(jsonDecode(utf8.decode(datagram.data))); - if (dto.fingerprint == syncState.securityContext.certificateHash) { + final sockets = await _getSockets( + whitelist: syncState.networkWhitelist, + blacklist: syncState.networkBlacklist, + multicastGroup: syncState.multicastGroup, + port: syncState.port, + ); + for (final socket in sockets) { + socket.socket.listen((_) { + final datagram = socket.socket.receive(); + if (datagram == null) { return; } - final ip = datagram.address.address; - final peer = dto.toDevice(ip, syncState.port, syncState.protocol == ProtocolType.https); - streamController.add(peer); - if ((dto.announcement == true || dto.announce == true) && syncState.serverRunning) { - // only respond when server is running - _answerAnnouncement(peer); + try { + final dto = MulticastDto.fromJson(jsonDecode(utf8.decode(datagram.data))); + if (dto.fingerprint == syncState.securityContext.certificateHash) { + return; + } + + final ip = datagram.address.address; + final peer = dto.toDevice(ip, syncState.port, syncState.protocol == ProtocolType.https); + streamController.add(peer); + if ((dto.announcement == true || dto.announce == true) && syncState.serverRunning) { + // only respond when server is running + _answerAnnouncement(peer); + } + } catch (e) { + _logger.warning('Could not parse multicast message', e); } - } catch (e) { - _logger.warning('Could not parse multicast message', e); + }); + _logger.info( + 'Bind UDP multicast port (ip: ${socket.interface.addresses.map((a) => a.address).toList()}, group: ${syncState.multicastGroup}, port: ${syncState.port})', + ); + } + + // Tell everyone in the network that I am online + sendAnnouncement(); // ignore: unawaited_futures + + _cancelCompleter = Completer(); + + // ignore: unawaited_futures + _cancelCompleter.future.then((_) { + streamController.close(); + for (final socket in sockets) { + socket.socket.close(); } }); - _logger.info( - 'Bind UDP multicast port (ip: ${socket.interface.addresses.map((a) => a.address).toList()}, group: ${syncState.multicastGroup}, port: ${syncState.port})', - ); + + yield* streamController.stream; + + // streamController is closed because of cancel + // wait for resources to be released (it works without on macOS, but who knows) + await sleepAsync(500); } + } - // Tell everyone in the network that I am online - sendAnnouncement(); // ignore: unawaited_futures - - yield* streamController.stream; + void restartListener() { + _cancelCompleter.complete(); } /// Sends an announcement which triggers a response on every LocalSend member of the network. Future sendAnnouncement() async { final syncState = _ref.read(syncProvider); - final sockets = await _getSockets(syncState.multicastGroup); + final sockets = await _getSockets( + whitelist: syncState.networkWhitelist, + blacklist: syncState.networkBlacklist, + multicastGroup: syncState.multicastGroup, + ); final dto = _getMulticastDto(announcement: true); for (final wait in [100, 500, 2000]) { await sleepAsync(wait); @@ -106,7 +137,11 @@ class MulticastService { } catch (e) { // Fallback: Answer with UDP final syncState = _ref.read(syncProvider); - final sockets = await _getSockets(syncState.multicastGroup); + final sockets = await _getSockets( + whitelist: syncState.networkWhitelist, + blacklist: syncState.networkBlacklist, + multicastGroup: syncState.multicastGroup, + ); final dto = _getMulticastDto(announcement: false); for (final socket in sockets) { try { @@ -160,8 +195,16 @@ class _SocketResult { _SocketResult(this.interface, this.socket); } -Future> _getSockets(String multicastGroup, [int? port]) async { - final interfaces = await NetworkInterface.list(); +Future> _getSockets({ + required List? whitelist, + required List? blacklist, + required String multicastGroup, + int? port, +}) async { + final interfaces = await getNetworkInterfaces( + whitelist: whitelist, + blacklist: blacklist, + ); final sockets = <_SocketResult>[]; for (final interface in interfaces) { try { diff --git a/common/lib/util/network_interfaces.dart b/common/lib/util/network_interfaces.dart new file mode 100644 index 00000000..bcf143ef --- /dev/null +++ b/common/lib/util/network_interfaces.dart @@ -0,0 +1,68 @@ +import 'dart:io'; + +import 'package:logging/logging.dart'; + +final _logger = Logger('NetworkInterface'); + +/// Returns a list of network interfaces respecting the whitelist and blacklist. +Future> getNetworkInterfaces({ + required List? whitelist, + required List? blacklist, +}) async { + final result = []; + final digestedWhitelist = whitelist?.map(buildRegExpFromIpFilter).toList(); + final digestedBlacklist = blacklist?.map(buildRegExpFromIpFilter).toList(); + + for (final interface in await NetworkInterface.list()) { + if (isNetworkIgnored( + networkWhitelist: digestedWhitelist, + networkBlacklist: digestedBlacklist, + interface: interface.addresses.map((a) => a.address).toList(), + )) { + _logger.info('Ignore network interface ${interface.name} (${interface.addresses.map((a) => a.address).toList()})'); + continue; + } + result.add(interface); + } + + return result; +} + +/// Returns true if the given IP should be ignored. +/// - When the IP is not in the whitelist (if the whitelist is not null) +/// - When the IP is in the blacklist (if the blacklist is not null) +bool isNetworkIgnoredRaw({ + required List? networkWhitelist, + required List? networkBlacklist, + required List interface, +}) { + return isNetworkIgnored( + networkWhitelist: networkWhitelist?.map(buildRegExpFromIpFilter).toList(), + networkBlacklist: networkBlacklist?.map(buildRegExpFromIpFilter).toList(), + interface: interface, + ); +} + +/// Builds a regular expression from the given IP. +/// - '123.123.124.*' -> '^123\.123\.124\.[^.]+$' +/// - '1::1:*:3' -> '^1::1:[^.]+:3$' +RegExp buildRegExpFromIpFilter(String ip) { + return RegExp('^${ip.replaceAll('.', '\\.').replaceAll('*', '[^.]+')}\$'); +} + +/// Returns true if the given IP should be ignored. +/// - When the IP is not in the whitelist (if the whitelist is not null) +/// - When the IP is in the blacklist (if the blacklist is not null) +bool isNetworkIgnored({ + required List? networkWhitelist, + required List? networkBlacklist, + required List interface, +}) { + if (networkWhitelist != null && !interface.any((a) => networkWhitelist.any((w) => w.hasMatch(a)))) { + return true; + } + if (networkBlacklist != null && interface.any((a) => networkBlacklist.any((b) => b.hasMatch(a)))) { + return true; + } + return false; +} diff --git a/common/test/unit/util/network_interfaces_test.dart b/common/test/unit/util/network_interfaces_test.dart new file mode 100644 index 00000000..563e5717 --- /dev/null +++ b/common/test/unit/util/network_interfaces_test.dart @@ -0,0 +1,49 @@ +import 'package:common/util/network_interfaces.dart'; +import 'package:test/test.dart'; + +void main() { + test('Should allow any IP', () { + ignored(String ip) => isNetworkIgnoredRaw(networkWhitelist: null, networkBlacklist: null, interface: [ip]); + + expect(ignored('1.2.3.4'), false); + expect(ignored('1::1'), false); + }); + + test('Should only allow explicit whitelisted IP', () { + ignored(String ip) => isNetworkIgnoredRaw(networkWhitelist: ['2.2.2.3'], networkBlacklist: null, interface: [ip]); + + expect(ignored('2.2.2.2'), true); + expect(ignored('2.2.2.3'), false); + }); + + test('Should allow wildcard whitelisted IP', () { + ignored(String ip) => isNetworkIgnoredRaw(networkWhitelist: ['3.3.3.*'], networkBlacklist: null, interface: [ip]); + + expect(ignored('3.3.3.1'), false); + expect(ignored('3.3.3.2'), false); + expect(ignored('3.3.4.3'), true); + }); + + test('Should allow wildcard whitelisted ipv6', () { + ignored(String ip) => isNetworkIgnoredRaw(networkWhitelist: ['1::1:*'], networkBlacklist: null, interface: [ip]); + + expect(ignored('1::1:1'), false); + expect(ignored('1::1:2'), false); + expect(ignored('1::2:1'), true); + }); + + test('Should ignore explicit blacklisted IP', () { + ignored(String ip) => isNetworkIgnoredRaw(networkWhitelist: null, networkBlacklist: ['3.3.3.4'], interface: [ip]); + + expect(ignored('3.3.3.3'), false); + expect(ignored('3.3.3.4'), true); + }); + + test('Should ignore wildcard blacklisted IP', () { + ignored(String ip) => isNetworkIgnoredRaw(networkWhitelist: null, networkBlacklist: ['3.3.3.*'], interface: [ip]); + + expect(ignored('3.3.3.3'), true); + expect(ignored('3.3.3.4'), true); + expect(ignored('4.3.3.3'), false); + }); +}