mirror of
https://github.com/localsend/localsend.git
synced 2026-06-23 04:10:07 +00:00
feat: add advanced setting to filter network interfaces (#2167)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -156,6 +156,8 @@ Future<RefenaContainer> preInit(List<String> 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,6 +41,7 @@ class Translations implements BaseTranslations<AppLocale, Translations> {
|
||||
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);
|
||||
|
||||
@@ -15,6 +15,8 @@ class SettingsState with SettingsStateMappable {
|
||||
final ColorMode colorMode;
|
||||
final AppLocale? locale;
|
||||
final int port;
|
||||
final List<String>? networkWhitelist; // null = disabled
|
||||
final List<String>? 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,
|
||||
|
||||
@@ -33,6 +33,10 @@ class SettingsStateMapper extends ClassMapperBase<SettingsState> {
|
||||
static const Field<SettingsState, AppLocale> _f$locale = Field('locale', _$locale);
|
||||
static int _$port(SettingsState v) => v.port;
|
||||
static const Field<SettingsState, int> _f$port = Field('port', _$port);
|
||||
static List<String>? _$networkWhitelist(SettingsState v) => v.networkWhitelist;
|
||||
static const Field<SettingsState, List<String>> _f$networkWhitelist = Field('networkWhitelist', _$networkWhitelist);
|
||||
static List<String>? _$networkBlacklist(SettingsState v) => v.networkBlacklist;
|
||||
static const Field<SettingsState, List<String>> _f$networkBlacklist = Field('networkBlacklist', _$networkBlacklist);
|
||||
static String _$multicastGroup(SettingsState v) => v.multicastGroup;
|
||||
static const Field<SettingsState, String> _f$multicastGroup = Field('multicastGroup', _$multicastGroup);
|
||||
static String? _$destination(SettingsState v) => v.destination;
|
||||
@@ -78,6 +82,8 @@ class SettingsStateMapper extends ClassMapperBase<SettingsState> {
|
||||
#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<SettingsState> {
|
||||
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<String>? networkWhitelist,
|
||||
List<String>? networkBlacklist,
|
||||
String? multicastGroup,
|
||||
String? destination,
|
||||
bool? saveToGallery,
|
||||
@@ -205,6 +217,14 @@ class _SettingsStateCopyWithImpl<$R, $Out> extends ClassCopyWithBase<$R, Setting
|
||||
@override
|
||||
late final ClassMapperBase<SettingsState> $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),
|
||||
|
||||
@@ -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<NetworkInterfacesPage> createState() => _NetworkInterfacesPageState();
|
||||
}
|
||||
|
||||
class _NetworkInterfacesPageState extends State<NetworkInterfacesPage> {
|
||||
List<(String, List<String>)> 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<void> Function(List<String>?) 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SettingsTabController, SettingsTabVm>((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<SettingsTabController, Setti
|
||||
class SettingsTabController extends ReduxNotifier<SettingsTabVm> {
|
||||
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<SettingsTabVm> {
|
||||
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
|
||||
|
||||
@@ -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<LocalIpService, NetworkState>((ref) {
|
||||
return LocalIpService();
|
||||
return LocalIpService(
|
||||
ref.notifier(settingsProvider),
|
||||
);
|
||||
});
|
||||
|
||||
StreamSubscription? _subscription;
|
||||
|
||||
class LocalIpService extends ReduxNotifier<NetworkState> {
|
||||
LocalIpService();
|
||||
final SettingsService _settingsService;
|
||||
|
||||
LocalIpService(this._settingsService);
|
||||
|
||||
@override
|
||||
NetworkState init() {
|
||||
@@ -44,11 +49,11 @@ class InitLocalIpAction extends ReduxAction<LocalIpService, NetworkState> {
|
||||
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<LocalIpService, NetworkState> {
|
||||
@override
|
||||
void after() {
|
||||
// ignore: discarded_futures
|
||||
dispatchAsync(_FetchLocalIpAction());
|
||||
dispatchAsync(FetchLocalIpAction());
|
||||
}
|
||||
}
|
||||
|
||||
class _FetchLocalIpAction extends AsyncReduxAction<LocalIpService, NetworkState> {
|
||||
class FetchLocalIpAction extends AsyncReduxAction<LocalIpService, NetworkState> {
|
||||
@override
|
||||
Future<NetworkState> reduce() async {
|
||||
return NetworkState(
|
||||
localIps: await _getIp(),
|
||||
localIps: await _getIp(
|
||||
whitelist: notifier._settingsService.state.networkWhitelist,
|
||||
blacklist: notifier._settingsService.state.networkBlacklist,
|
||||
),
|
||||
initialized: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>> _getIp() async {
|
||||
Future<List<String>> _getIp({
|
||||
required List<String>? whitelist,
|
||||
required List<String>? blacklist,
|
||||
}) async {
|
||||
final info = plugin.NetworkInfo();
|
||||
String? ip;
|
||||
try {
|
||||
@@ -82,16 +93,14 @@ Future<List<String>> _getIp() async {
|
||||
_logger.warning('Failed to get wifi IP', e);
|
||||
}
|
||||
|
||||
List<String> 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');
|
||||
|
||||
@@ -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<String>? getNetworkWhitelist() {
|
||||
return _prefs.getStringList(_networkWhitelistKey);
|
||||
}
|
||||
|
||||
Future<void> setNetworkWhitelist(List<String>? whitelist) async {
|
||||
if (whitelist == null) {
|
||||
await _prefs.remove(_networkWhitelistKey);
|
||||
} else {
|
||||
await _prefs.setStringList(_networkWhitelistKey, whitelist);
|
||||
}
|
||||
}
|
||||
|
||||
List<String>? getNetworkBlacklist() {
|
||||
return _prefs.getStringList(_networkBlacklistKey);
|
||||
}
|
||||
|
||||
Future<void> setNetworkBlacklist(List<String>? blacklist) async {
|
||||
if (blacklist == null) {
|
||||
await _prefs.remove(_networkBlacklistKey);
|
||||
} else {
|
||||
await _prefs.setStringList(_networkBlacklistKey, blacklist);
|
||||
}
|
||||
}
|
||||
|
||||
int getDiscoveryTimeout() {
|
||||
return _prefs.getInt(_timeoutKey) ?? defaultDiscoveryTimeout;
|
||||
}
|
||||
|
||||
@@ -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<SettingsService, SettingsState>((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<SettingsState> {
|
||||
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<SettingsState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setNetworkWhitelist(List<String>? whitelist) async {
|
||||
await _persistence.setNetworkWhitelist(whitelist);
|
||||
state = state.copyWith(
|
||||
networkWhitelist: whitelist,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setNetworkBlacklist(List<String>? blacklist) async {
|
||||
await _persistence.setNetworkBlacklist(blacklist);
|
||||
state = state.copyWith(
|
||||
networkBlacklist: blacklist,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setDiscoveryTimeout(int timeout) async {
|
||||
await _persistence.setDiscoveryTimeout(timeout);
|
||||
state = state.copyWith(
|
||||
|
||||
@@ -10,12 +10,14 @@ import 'package:routerino/routerino.dart';
|
||||
class TextFieldTv extends StatefulWidget {
|
||||
final String name;
|
||||
final TextEditingController controller;
|
||||
final ValueChanged<String> onChanged;
|
||||
final ValueChanged<String>? 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<TextFieldTv> 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
@@ -258,6 +258,26 @@ class MockPersistenceService extends _i1.Mock implements _i3.PersistenceService
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> setNetworkWhitelist(List<String>? whitelist) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#setNetworkWhitelist,
|
||||
[whitelist],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> setNetworkBlacklist(List<String>? blacklist) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#setNetworkBlacklist,
|
||||
[blacklist],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
int getDiscoveryTimeout() => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
|
||||
+1
-17
@@ -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"
|
||||
|
||||
@@ -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<void> setupMulticastDiscoveryIsolate(
|
||||
Stream<SendToIsolateData<MulticastAnnouncementTask>> receiveFromMain,
|
||||
Stream<SendToIsolateData<MulticastTask>> receiveFromMain,
|
||||
void Function(Device) sendToMain,
|
||||
InitialData initialData,
|
||||
) async {
|
||||
@@ -33,7 +42,14 @@ Future<void> 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;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ class SyncState with SyncStateMappable {
|
||||
final DeviceInfoResult deviceInfo;
|
||||
final String alias;
|
||||
final int port;
|
||||
final List<String>? networkWhitelist;
|
||||
final List<String>? 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: <SecurityContext>, deviceInfo: $deviceInfo, alias: $alias, port: $port, protocol: $protocol, multicastGroup: $multicastGroup, discoveryTimeout: $discoveryTimeout, serverRunning: $serverRunning, download: $download)';
|
||||
return 'SyncState(securityContext: <SecurityContext>, deviceInfo: $deviceInfo, alias: $alias, port: $port, networkWhitelist: $networkWhitelist, networkBlacklist: $networkBlacklist, protocol: $protocol, multicastGroup: $multicastGroup, discoveryTimeout: $discoveryTimeout, serverRunning: $serverRunning, download: $download)';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@ class SyncStateMapper extends ClassMapperBase<SyncState> {
|
||||
static const Field<SyncState, String> _f$alias = Field('alias', _$alias);
|
||||
static int _$port(SyncState v) => v.port;
|
||||
static const Field<SyncState, int> _f$port = Field('port', _$port);
|
||||
static List<String>? _$networkWhitelist(SyncState v) => v.networkWhitelist;
|
||||
static const Field<SyncState, List<String>> _f$networkWhitelist = Field('networkWhitelist', _$networkWhitelist);
|
||||
static List<String>? _$networkBlacklist(SyncState v) => v.networkBlacklist;
|
||||
static const Field<SyncState, List<String>> _f$networkBlacklist = Field('networkBlacklist', _$networkBlacklist);
|
||||
static ProtocolType _$protocol(SyncState v) => v.protocol;
|
||||
static const Field<SyncState, ProtocolType> _f$protocol = Field('protocol', _$protocol);
|
||||
static String _$multicastGroup(SyncState v) => v.multicastGroup;
|
||||
@@ -58,6 +62,8 @@ class SyncStateMapper extends ClassMapperBase<SyncState> {
|
||||
#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<SyncState> {
|
||||
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<void> 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<String>? networkWhitelist,
|
||||
List<String>? 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<void> 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),
|
||||
|
||||
@@ -131,8 +131,6 @@ class IsolateFavoriteHttpDiscoveryAction extends ReduxActionWithResult<IsolateCo
|
||||
}
|
||||
|
||||
class IsolateSendMulticastAnnouncementAction extends ReduxAction<IsolateController, ParentIsolateState> {
|
||||
IsolateSendMulticastAnnouncementAction();
|
||||
|
||||
@override
|
||||
ParentIsolateState reduce() {
|
||||
final connection = state.multicastDiscovery;
|
||||
@@ -149,6 +147,23 @@ class IsolateSendMulticastAnnouncementAction extends ReduxAction<IsolateControll
|
||||
}
|
||||
}
|
||||
|
||||
class IsolateSendMulticastRestartListenerAction extends ReduxAction<IsolateController, ParentIsolateState> {
|
||||
@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<double> progress;
|
||||
|
||||
@@ -45,10 +45,14 @@ class IsolateSyncDeviceInfoAction extends ReduxAction<IsolateController, ParentI
|
||||
}
|
||||
|
||||
class IsolateSyncSettingsAction extends ReduxAction<IsolateController, ParentIsolateState> {
|
||||
final List<String>? networkWhitelist;
|
||||
final List<String>? 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<IsolateController, ParentIso
|
||||
ParentIsolateState reduce() {
|
||||
dispatch(_PublishSyncStateAction(
|
||||
syncState: state.syncState.copyWith(
|
||||
networkWhitelist: networkWhitelist,
|
||||
networkBlacklist: networkBlacklist,
|
||||
multicastGroup: multicastGroup,
|
||||
discoveryTimeout: discoveryTimeout,
|
||||
),
|
||||
|
||||
@@ -25,7 +25,7 @@ class ParentIsolateState with ParentIsolateStateMappable {
|
||||
final SyncState syncState;
|
||||
final IsolateConnector<IsolateTaskStreamResult<Device>, SendToIsolateData<IsolateTask<HttpScanTask>>>? httpScanDiscovery;
|
||||
final IsolateConnector<IsolateTaskResult<Device>, SendToIsolateData<IsolateTask<HttpTargetTask>>>? httpTargetDiscovery;
|
||||
final IsolateConnector<Device, SendToIsolateData<MulticastAnnouncementTask>>? multicastDiscovery;
|
||||
final IsolateConnector<Device, SendToIsolateData<MulticastTask>>? multicastDiscovery;
|
||||
final List<IsolateConnector<IsolateTaskStreamResult<double>, SendToIsolateData<IsolateTask<BaseHttpUploadTask>>>> httpUpload;
|
||||
int get uploadIsolateCount => httpUpload.length;
|
||||
|
||||
@@ -94,7 +94,7 @@ class IsolateSetupAction extends AsyncReduxAction<IsolateController, ParentIsola
|
||||
),
|
||||
);
|
||||
|
||||
final multicastDiscovery = await startIsolate<Device, SendToIsolateData<MulticastAnnouncementTask>, InitialData>(
|
||||
final multicastDiscovery = await startIsolate<Device, SendToIsolateData<MulticastTask>, InitialData>(
|
||||
task: setupMulticastDiscoveryIsolate,
|
||||
param: InitialData(
|
||||
syncState: state.syncState,
|
||||
|
||||
@@ -32,8 +32,8 @@ class ParentIsolateStateMapper extends ClassMapperBase<ParentIsolateState> {
|
||||
v.httpTargetDiscovery;
|
||||
static const Field<ParentIsolateState, IsolateConnector<IsolateTaskResult<Device>, SendToIsolateData<IsolateTask<HttpTargetTask>>>>
|
||||
_f$httpTargetDiscovery = Field('httpTargetDiscovery', _$httpTargetDiscovery);
|
||||
static IsolateConnector<Device, SendToIsolateData<MulticastAnnouncementTask>>? _$multicastDiscovery(ParentIsolateState v) => v.multicastDiscovery;
|
||||
static const Field<ParentIsolateState, IsolateConnector<Device, SendToIsolateData<MulticastAnnouncementTask>>> _f$multicastDiscovery =
|
||||
static IsolateConnector<Device, SendToIsolateData<MulticastTask>>? _$multicastDiscovery(ParentIsolateState v) => v.multicastDiscovery;
|
||||
static const Field<ParentIsolateState, IsolateConnector<Device, SendToIsolateData<MulticastTask>>> _f$multicastDiscovery =
|
||||
Field('multicastDiscovery', _$multicastDiscovery);
|
||||
static List<IsolateConnector<IsolateTaskStreamResult<double>, SendToIsolateData<IsolateTask<BaseHttpUploadTask>>>> _$httpUpload(
|
||||
ParentIsolateState v) =>
|
||||
@@ -114,7 +114,7 @@ abstract class ParentIsolateStateCopyWith<$R, $In extends ParentIsolateState, $O
|
||||
{SyncState? syncState,
|
||||
IsolateConnector<IsolateTaskStreamResult<Device>, SendToIsolateData<IsolateTask<HttpScanTask>>>? httpScanDiscovery,
|
||||
IsolateConnector<IsolateTaskResult<Device>, SendToIsolateData<IsolateTask<HttpTargetTask>>>? httpTargetDiscovery,
|
||||
IsolateConnector<Device, SendToIsolateData<MulticastAnnouncementTask>>? multicastDiscovery,
|
||||
IsolateConnector<Device, SendToIsolateData<MulticastTask>>? multicastDiscovery,
|
||||
List<IsolateConnector<IsolateTaskStreamResult<double>, SendToIsolateData<IsolateTask<BaseHttpUploadTask>>>>? httpUpload});
|
||||
ParentIsolateStateCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t);
|
||||
}
|
||||
|
||||
@@ -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<void> _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<Device>();
|
||||
final syncState = _ref.read(syncProvider);
|
||||
while (true) {
|
||||
final streamController = StreamController<Device>();
|
||||
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<void> 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<List<_SocketResult>> _getSockets(String multicastGroup, [int? port]) async {
|
||||
final interfaces = await NetworkInterface.list();
|
||||
Future<List<_SocketResult>> _getSockets({
|
||||
required List<String>? whitelist,
|
||||
required List<String>? blacklist,
|
||||
required String multicastGroup,
|
||||
int? port,
|
||||
}) async {
|
||||
final interfaces = await getNetworkInterfaces(
|
||||
whitelist: whitelist,
|
||||
blacklist: blacklist,
|
||||
);
|
||||
final sockets = <_SocketResult>[];
|
||||
for (final interface in interfaces) {
|
||||
try {
|
||||
|
||||
@@ -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<List<NetworkInterface>> getNetworkInterfaces({
|
||||
required List<String>? whitelist,
|
||||
required List<String>? blacklist,
|
||||
}) async {
|
||||
final result = <NetworkInterface>[];
|
||||
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<String>? networkWhitelist,
|
||||
required List<String>? networkBlacklist,
|
||||
required List<String> 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<RegExp>? networkWhitelist,
|
||||
required List<RegExp>? networkBlacklist,
|
||||
required List<String> 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user