feat: show found webrtc devices

This commit is contained in:
Tien Do Nam
2025-02-01 02:15:40 +01:00
parent 70c8c2871a
commit b282e89db0
22 changed files with 290 additions and 109 deletions

View File

@@ -21,6 +21,7 @@ import 'package:localsend_app/provider/app_arguments_provider.dart';
import 'package:localsend_app/provider/device_info_provider.dart';
import 'package:localsend_app/provider/network/nearby_devices_provider.dart';
import 'package:localsend_app/provider/network/server/server_provider.dart';
import 'package:localsend_app/provider/network/webrtc/signaling_provider.dart';
import 'package:localsend_app/provider/persistence_provider.dart';
// [FOSS_REMOVE_START]
@@ -32,8 +33,6 @@ import 'package:localsend_app/provider/settings_provider.dart';
import 'package:localsend_app/provider/tv_provider.dart';
import 'package:localsend_app/provider/window_dimensions_provider.dart';
import 'package:localsend_app/rust/api/logging.dart' as rust_logging;
import 'package:localsend_app/rust/api/model.dart' as model;
import 'package:localsend_app/rust/api/webrtc.dart' as webrtc;
import 'package:localsend_app/rust/frb_generated.dart';
import 'package:localsend_app/util/i18n.dart';
import 'package:localsend_app/util/native/autostart_helper.dart';
@@ -65,55 +64,13 @@ Future<RefenaContainer> preInit(List<String> args) async {
await RustLib.init();
await rust_logging.enableDebugLogging();
webrtc.LsSignalingConnection? connection;
final stream = webrtc.connect(
uri: 'wss://public.localsend.org/v1/ws',
info: webrtc.ClientInfoWithoutId(
alias: 'alias',
version: 'version',
deviceModel: 'deviceModel',
deviceType: webrtc.PeerDeviceType.mobile,
fingerprint: 'fingerprint',
),
onConnection: (c) {
connection = c;
print('Got connection');
},
);
const stunServers = ['stun:stun.l.google.com:19302'];
stream.listen((message) async {
print('Got message: $message');
webrtc.WsServerMessage;
await message.when(
hello: (_, __) {},
joined: (j) async {
await connection?.sendOffer(
stunServers: stunServers,
target: j.id,
files: [
model.FileDto(
id: '1',
fileName: 'test.mp4',
size: BigInt.from(1),
fileType: 'fileType',
),
],
);
},
left: (l) {},
offer: (o) async {
await connection?.acceptOffer(stunServers: stunServers, offer: o);
},
answer: (a) {},
error: (e) {},
);
}, onDone: () {
print('Done!!!');
});
if (kDebugMode) {
try {
await rust_logging.enableDebugLogging();
} catch (e) {
_logger.warning('Enabling debug logging failed', e);
}
}
await Rhttp.init();
@@ -259,6 +216,8 @@ Future<void> postInit(BuildContext context, Ref ref, bool appStart) async {
_logger.warning('Starting multicast listener failed', e);
}
ref.redux(signalingProvider).dispatch(SetupSignalingConnection());
if (appStart) {
if (defaultTargetPlatform == TargetPlatform.macOS) {
// handle dropped files

View File

@@ -20,4 +20,41 @@ class NearbyDevicesState with NearbyDevicesStateMappable {
required this.devices,
required this.signalingDevices,
});
Map<String, Device> get allDevices {
final Map<String, Device> allDevices = {};
allDevices.addAll(devices);
for (final devices in signalingDevices.values) {
for (final device in devices) {
final currentDevice = allDevices[device.fingerprint];
if (currentDevice != null && currentDevice.alias == device.alias) {
allDevices[device.fingerprint] = currentDevice.merge(device);
} else {
allDevices[device.fingerprint] = device;
}
}
}
return allDevices;
}
}
extension on Device {
Device merge(Device other) {
return Device(
signalingId: signalingId ?? other.signalingId,
ip: ip ?? other.ip,
version: version,
port: port,
https: https,
fingerprint: fingerprint,
alias: alias,
deviceModel: deviceModel,
deviceType: deviceType,
download: download,
discoveryMethods: {
...discoveryMethods,
...other.discoveryMethods,
},
);
}
}

View File

@@ -62,6 +62,7 @@ class ReceivePageController extends ReduxNotifier<ReceivePageVm> {
return ReceivePageVm(
status: SessionStatus.waiting,
sender: const Device(
signalingId: null,
ip: '0.0.0.0',
version: '1.0.0',
port: 8080,
@@ -155,6 +156,7 @@ class InitReceivePageFromHistoryMessageAction extends ReduxAction<ReceivePageCon
ReceivePageVm reduce() {
return state.copyWith(
sender: Device(
signalingId: null,
ip: '0.0.0.0',
version: '1.0.0',
port: 8080,

View File

@@ -219,7 +219,7 @@ class SendTab extends StatelessWidget {
device: device,
isFavorite: favoriteEntry != null,
nameOverride: favoriteEntry?.alias,
onFavoriteTap: () async => await vm.onToggleFavorite(context, device),
onFavoriteTap: device.ip == null ? null : () async => await vm.onToggleFavorite(context, device),
onTap: () async => await vm.onTapDevice(context, device),
),
),
@@ -534,7 +534,7 @@ class _MultiSendDeviceListTile extends StatelessWidget {
progress: progress,
isFavorite: isFavorite,
nameOverride: nameOverride,
onFavoriteTap: () async => await vm.onToggleFavorite(context, device),
onFavoriteTap: device.ip == null ? null : () async => await vm.onToggleFavorite(context, device),
onTap: () async => await vm.onTapDeviceMultiSend(context, device),
);
}

View File

@@ -56,7 +56,7 @@ final sendTabVmProvider = ViewProvider((ref) {
final sendMode = ref.watch(settingsProvider.select((s) => s.sendMode));
final selectedFiles = ref.watch(selectedSendingFilesProvider);
final localIps = ref.watch(localIpProvider).localIps;
final nearbyDevices = ref.watch(nearbyDevicesProvider).devices.values;
final nearbyDevices = ref.watch(nearbyDevicesProvider).allDevices.values;
final favoriteDevices = ref.watch(favoritesProvider);
return SendTabVm(

View File

@@ -32,6 +32,7 @@ final deviceFullInfoProvider = ViewProvider((ref) {
final rawInfo = ref.watch(deviceInfoProvider);
final securityContext = ref.read(securityProvider);
return Device(
signalingId: null,
ip: networkInfo.localIps.firstOrNull ?? '-',
version: protocolVersion,
port: serverState?.port ?? -1,

View File

@@ -79,7 +79,7 @@ class RegisterDeviceAction extends AsyncReduxAction<NearbyDevicesService, Nearby
@override
Future<NearbyDevicesState> reduce() async {
assert(device.ip?.isNotEmpty ?? false);
assert(device.ip?.isNotEmpty ?? false, 'IP must not be empty');
final favoriteDevice = notifier._favoriteService.state.firstWhereOrNull((e) => e.fingerprint == device.fingerprint);
if (favoriteDevice != null && !favoriteDevice.customAlias) {
@@ -94,6 +94,41 @@ class RegisterDeviceAction extends AsyncReduxAction<NearbyDevicesService, Nearby
}
}
/// Registers a new device found via signaling.
class RegisterSignalingDeviceAction extends ReduxAction<NearbyDevicesService, NearbyDevicesState> {
final Device device;
RegisterSignalingDeviceAction(this.device);
@override
NearbyDevicesState reduce() {
final Set<Device> existingDevices = state.signalingDevices[device.fingerprint] ?? {};
existingDevices.add(device);
return state.copyWith(
signalingDevices: {
...state.signalingDevices,
device.fingerprint: existingDevices,
},
);
}
}
class UnregisterSignalingDeviceAction extends ReduxAction<NearbyDevicesService, NearbyDevicesState> {
final String signalingId;
UnregisterSignalingDeviceAction(this.signalingId);
@override
NearbyDevicesState reduce() {
return state.copyWith(
signalingDevices: {
for (final entry in state.signalingDevices.entries) entry.key: entry.value.where((e) => e.signalingId != signalingId).toSet(),
},
);
}
}
/// It does not really "scan".
/// It just sends an announcement which will cause a response on every other LocalSend member of the network.
class StartMulticastScan extends ReduxAction<NearbyDevicesService, NearbyDevicesState> {

View File

@@ -1,16 +1,19 @@
import 'dart:async';
import 'package:common/constants.dart';
import 'package:common/model/device.dart';
import 'package:common/model/device_info_result.dart';
import 'package:dart_mappable/dart_mappable.dart';
import 'package:localsend_app/provider/device_info_provider.dart';
import 'package:localsend_app/provider/network/nearby_devices_provider.dart';
import 'package:localsend_app/provider/persistence_provider.dart';
import 'package:localsend_app/provider/security_provider.dart';
import 'package:localsend_app/provider/settings_provider.dart';
import 'package:localsend_app/rust/api/webrtc.dart';
import 'package:refena_flutter/refena_flutter.dart';
part 'signaling_provider.mapper.dart';
final signalingProvider = ReduxProvider<SignalingService, SignalingState>((ref) {
return SignalingService(
persistence: ref.read(persistenceProvider),
);
});
@MappableClass()
class SignalingState with SignalingStateMappable {
final List<String> signalingServers;
@@ -24,26 +27,50 @@ class SignalingState with SignalingStateMappable {
});
}
final signalingProvider = ReduxProvider<SignalingService, SignalingState>((ref) {
return SignalingService(
persistence: ref.read(persistenceProvider),
nearbyDevices: ref.notifier(nearbyDevicesProvider),
settings: ref.notifier(settingsProvider),
deviceInfo: ref.accessor(deviceInfoProvider),
security: ref.notifier(securityProvider),
);
});
class SignalingService extends ReduxNotifier<SignalingState> {
final PersistenceService _persistence;
final NearbyDevicesService _nearbyDevices;
final SettingsService _settings;
final StateAccessor<DeviceInfoResult> _deviceInfo;
final SecurityService _security;
SignalingService({required PersistenceService persistence}) : _persistence = persistence;
SignalingService({
required PersistenceService persistence,
required NearbyDevicesService nearbyDevices,
required SettingsService settings,
required StateAccessor<DeviceInfoResult> deviceInfo,
required SecurityService security,
}) : _persistence = persistence,
_nearbyDevices = nearbyDevices,
_settings = settings,
_deviceInfo = deviceInfo,
_security = security;
@override
SignalingState init() {
return SignalingState(
signalingServers: _persistence.getSignalingServers(),
stunServers: _persistence.getStunServers(),
signalingServers: _persistence.getSignalingServers() ?? ['wss://public.localsend.org/v1/ws'],
stunServers: _persistence.getStunServers() ?? ['stun:stun.localsend.org:5349'],
connections: {},
);
}
}
class SetupSignalingConnection extends AsyncReduxAction<SignalingService, SignalingState> {
class SetupSignalingConnection extends ReduxAction<SignalingService, SignalingState> {
@override
Future<SignalingState> reduce() async {
SignalingState reduce() {
for (final signalingServer in state.signalingServers) {
// ignore: unawaited_futures
// ignore: discarded_futures
dispatchAsync(_SetupSignalingConnection(signalingServer: signalingServer));
}
return state;
@@ -62,19 +89,133 @@ class _SetupSignalingConnection extends AsyncReduxAction<SignalingService, Signa
final stream = connect(
uri: 'wss://public.localsend.org/v1/ws',
info: ClientInfoWithoutId(
alias: 'alias',
version: 'version',
deviceModel: 'deviceModel',
deviceType: PeerDeviceType.mobile,
fingerprint: 'fingerprint',
alias: notifier._settings.state.alias,
version: protocolVersion,
deviceModel: notifier._deviceInfo.state.deviceModel,
deviceType: notifier._deviceInfo.state.deviceType.toPeerDeviceType(),
fingerprint: notifier._security.state.certificateHash,
),
onConnection: (c) {
connection = c;
dispatch(_SetConnectionAction(
signalingServer: signalingServer,
connection: c,
));
},
);
await for (final message in stream) {}
try {
await for (final message in stream) {
switch (message) {
case WsServerMessage_Hello():
for (final d in message.peers) {
external(notifier._nearbyDevices).dispatch(RegisterSignalingDeviceAction(
d.toDevice(signalingServer),
));
}
break;
case WsServerMessage_Joined():
external(notifier._nearbyDevices).dispatch(RegisterSignalingDeviceAction(
message.peer.toDevice(signalingServer),
));
break;
case WsServerMessage_Left():
external(notifier._nearbyDevices).dispatch(UnregisterSignalingDeviceAction(
message.peerId.uuid,
));
break;
case WsServerMessage_Offer():
case WsServerMessage_Answer():
case WsServerMessage_Error():
}
}
} finally {
dispatch(_RemoveConnectionAction(signalingServer: signalingServer));
}
return state;
}
}
class _SetConnectionAction extends ReduxAction<SignalingService, SignalingState> {
final String signalingServer;
final LsSignalingConnection connection;
_SetConnectionAction({
required this.signalingServer,
required this.connection,
});
@override
SignalingState reduce() {
return state.copyWith(
connections: {
...state.connections,
signalingServer: connection,
},
);
}
}
class _RemoveConnectionAction extends ReduxAction<SignalingService, SignalingState> {
final String signalingServer;
_RemoveConnectionAction({required this.signalingServer});
@override
SignalingState reduce() {
return state.copyWith(
connections: {
for (final entry in state.connections.entries)
if (entry.key != signalingServer) entry.key: entry.value,
},
);
}
}
extension on ClientInfo {
Device toDevice(String signalingServer) {
return Device(
signalingId: id.uuid,
ip: null,
version: version,
port: -1,
https: false,
fingerprint: fingerprint,
alias: alias,
deviceModel: deviceModel,
deviceType: deviceType?.toDeviceType() ?? DeviceType.desktop,
download: false,
discoveryMethods: {
SignalingDiscovery(
signalingServer: signalingServer,
),
},
);
}
}
extension on PeerDeviceType {
DeviceType toDeviceType() {
return switch (this) {
PeerDeviceType.mobile => DeviceType.mobile,
PeerDeviceType.desktop => DeviceType.desktop,
PeerDeviceType.web => DeviceType.web,
PeerDeviceType.headless => DeviceType.headless,
PeerDeviceType.server => DeviceType.server,
};
}
}
extension on DeviceType {
PeerDeviceType toPeerDeviceType() {
return switch (this) {
DeviceType.mobile => PeerDeviceType.mobile,
DeviceType.desktop => PeerDeviceType.desktop,
DeviceType.web => PeerDeviceType.web,
DeviceType.headless => PeerDeviceType.headless,
DeviceType.server => PeerDeviceType.server,
};
}
}

View File

@@ -212,10 +212,10 @@ class PersistenceService {
await _prefs.setString(_securityContext, jsonEncode(context));
}
List<String> getSignalingServers() {
List<String>? getSignalingServers() {
final serversRaw = _prefs.getString(_signalingServers);
if (serversRaw == null) {
return [];
return null;
}
return (jsonDecode(serversRaw) as List).cast<String>();
@@ -225,10 +225,10 @@ class PersistenceService {
await _prefs.setString(_signalingServers, jsonEncode(servers));
}
List<String> getStunServers() {
List<String>? getStunServers() {
final serversRaw = _prefs.getString(_stunServers);
if (serversRaw == null) {
return [];
return null;
}
return (jsonDecode(serversRaw) as List).cast<String>();

View File

@@ -565,7 +565,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: null,
decodeErrorData: sse_decode_AnyhowException,
),
constMeta: kCrateApiLoggingEnableDebugLoggingConstMeta,
argValues: [],

View File

@@ -59,7 +59,7 @@ class DeviceListTile extends StatelessWidget {
foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer,
label: '#${device.ip!.visualId}',
)
else
else
DeviceBadge(
backgroundColor: badgeColor,
foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer,

View File

@@ -1,7 +1,11 @@
use anyhow::Result;
use tracing::Level;
pub fn enable_debug_logging() {
pub fn enable_debug_logging() -> Result<()> {
tracing_subscriber::fmt()
.with_max_level(Level::DEBUG)
.init();
.try_init()
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
Ok(())
}

View File

@@ -1007,12 +1007,12 @@ fn wire__crate__api__logging__enable_debug_logging_impl(
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end();
move |context| {
transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok({
crate::api::logging::enable_debug_logging();
})?;
Ok(output_ok)
})())
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>(
(move || {
let output_ok = crate::api::logging::enable_debug_logging()?;
Ok(output_ok)
})(),
)
}
},
)

View File

@@ -94,16 +94,6 @@ class MockPersistenceService extends _i1.Mock implements _i3.PersistenceService
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
List<String> getSignalingServers() => (super.noSuchMethod(
Invocation.method(
#getSignalingServers,
[],
),
returnValue: <String>[],
returnValueForMissingStub: <String>[],
) as List<String>);
@override
_i4.Future<void> setSignalingServers(List<String>? servers) => (super.noSuchMethod(
Invocation.method(
@@ -114,16 +104,6 @@ class MockPersistenceService extends _i1.Mock implements _i3.PersistenceService
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
List<String> getStunServers() => (super.noSuchMethod(
Invocation.method(
#getStunServers,
[],
),
returnValue: <String>[],
returnValueForMissingStub: <String>[],
) as List<String>);
@override
_i4.Future<void> setStunServers(List<String>? servers) => (super.noSuchMethod(
Invocation.method(

View File

@@ -52,6 +52,7 @@ void main() {
Device _createDevice(String ip) {
return Device(
signalingId: null,
ip: ip,
version: '1',
port: 123,

View File

@@ -46,6 +46,7 @@ Device _target({
required bool https,
}) {
return Device(
signalingId: null,
ip: '0.0.0.0',
version: version,
port: 8080,

View File

@@ -24,6 +24,7 @@ class MulticastDiscovery extends DiscoveryMethod with MulticastDiscoveryMappable
@MappableClass()
class HttpDiscovery extends DiscoveryMethod with HttpDiscoveryMappable {
final String ip;
const HttpDiscovery({required this.ip});
}
@@ -47,7 +48,13 @@ enum TransmissionMethod {
/// It gets not serialized.
@MappableClass()
class Device with DeviceMappable {
/// A unique ID provided by the signaling server.
final String? signalingId;
/// The IP address of the device.
/// Is null when found via signaling.
final String? ip;
final String version;
final int port;
final bool https;
@@ -82,6 +89,7 @@ class Device with DeviceMappable {
}
const Device({
required this.signalingId,
required this.ip,
required this.version,
required this.port,

View File

@@ -399,6 +399,8 @@ class DeviceMapper extends ClassMapperBase<Device> {
@override
final String id = 'Device';
static String? _$signalingId(Device v) => v.signalingId;
static const Field<Device, String> _f$signalingId = Field('signalingId', _$signalingId);
static String? _$ip(Device v) => v.ip;
static const Field<Device, String> _f$ip = Field('ip', _$ip);
static String _$version(Device v) => v.version;
@@ -422,6 +424,7 @@ class DeviceMapper extends ClassMapperBase<Device> {
@override
final MappableFields<Device> fields = const {
#signalingId: _f$signalingId,
#ip: _f$ip,
#version: _f$version,
#port: _f$port,
@@ -436,6 +439,7 @@ class DeviceMapper extends ClassMapperBase<Device> {
static Device _instantiate(DecodingData data) {
return Device(
signalingId: data.dec(_f$signalingId),
ip: data.dec(_f$ip),
version: data.dec(_f$version),
port: data.dec(_f$port),
@@ -492,7 +496,8 @@ extension DeviceValueCopy<$R, $Out> on ObjectCopyWith<$R, Device, $Out> {
abstract class DeviceCopyWith<$R, $In extends Device, $Out> implements ClassCopyWith<$R, $In, $Out> {
$R call(
{String? ip,
{String? signalingId,
String? ip,
String? version,
int? port,
bool? https,
@@ -512,7 +517,8 @@ class _DeviceCopyWithImpl<$R, $Out> extends ClassCopyWithBase<$R, Device, $Out>
late final ClassMapperBase<Device> $mapper = DeviceMapper.ensureInitialized();
@override
$R call(
{Object? ip = $none,
{Object? signalingId = $none,
Object? ip = $none,
String? version,
int? port,
bool? https,
@@ -523,6 +529,7 @@ class _DeviceCopyWithImpl<$R, $Out> extends ClassCopyWithBase<$R, Device, $Out>
bool? download,
Set<DiscoveryMethod>? discoveryMethods}) =>
$apply(FieldCopyWithData({
if (signalingId != $none) #signalingId: signalingId,
if (ip != $none) #ip: ip,
if (version != null) #version: version,
if (port != null) #port: port,
@@ -536,6 +543,7 @@ class _DeviceCopyWithImpl<$R, $Out> extends ClassCopyWithBase<$R, Device, $Out>
}));
@override
Device $make(CopyWithData data) => Device(
signalingId: data.get(#signalingId, or: $value.signalingId),
ip: data.get(#ip, or: $value.ip),
version: data.get(#version, or: $value.version),
port: data.get(#port, or: $value.port),

View File

@@ -30,6 +30,7 @@ extension InfoToDeviceExt on InfoDto {
/// Since this HTTP request was successful, the [port] and [https] are known.
Device toDevice(String ip, int port, bool https, DiscoveryMethod method) {
return Device(
signalingId: null,
ip: ip,
version: version ?? fallbackProtocolVersion,
port: port,

View File

@@ -36,6 +36,7 @@ class InfoRegisterDto with InfoRegisterDtoMappable {
extension InfoRegisterDtoExt on InfoRegisterDto {
Device toDevice(String ip, int ownPort, bool ownHttps, DiscoveryMethod? method) {
return Device(
signalingId: null,
ip: ip,
version: version ?? fallbackProtocolVersion,
port: port ?? ownPort,

View File

@@ -39,6 +39,7 @@ class MulticastDto with MulticastDtoMappable {
extension MulticastDtoToDeviceExt on MulticastDto {
Device toDevice(String ip, int ownPort, bool ownHttps) {
return Device(
signalingId: null,
ip: ip,
version: version ?? fallbackProtocolVersion,
port: port ?? ownPort,

View File

@@ -33,6 +33,7 @@ class RegisterDto with RegisterDtoMappable {
extension RegisterDtoExt on RegisterDto {
Device toDevice(String ip, int ownPort, bool ownHttps, DiscoveryMethod method) {
return Device(
signalingId: null,
ip: ip,
version: version ?? fallbackProtocolVersion,
port: port ?? ownPort,