From 9e8a51d0039a0daca39735d90590ba80b08ccd22 Mon Sep 17 00:00:00 2001 From: Tien Do Nam Date: Mon, 20 Jul 2026 16:16:58 +0200 Subject: [PATCH] feat: drop Dart HTTP server --- .../localsend/localsend_app/MainActivity.kt | 59 ++ .../state/send/web/web_send_session.dart | 6 +- .../send/web/web_send_session.mapper.dart | 44 +- .../model/state/send/web/web_send_state.dart | 6 +- .../state/send/web/web_send_state.mapper.dart | 19 - .../state/server/receive_session_state.dart | 5 - .../server/receive_session_state.mapper.dart | 14 - app/lib/model/state/server/server_state.dart | 7 +- .../state/server/server_state.mapper.dart | 30 - app/lib/pages/tabs/settings_tab.dart | 6 + app/lib/pages/web_send_page.dart | 26 +- .../network/server/controller/common.dart | 38 - .../server/controller/receive_controller.dart | 822 +++++++----------- .../server/controller/send_controller.dart | 410 ++++----- .../network/server/server_provider.dart | 237 +++-- .../util/native/channel/android_channel.dart | 32 + app/lib/util/native/file_saver.dart | 252 +++--- app/lib/util/simple_server.dart | 143 --- app/lib/widget/dialogs/qr_dialog.dart | 2 +- app/lib/widget/dialogs/zoom_dialog.dart | 2 +- packages/core/Cargo.lock | 1 + packages/core/Cargo.toml | 2 +- packages/core/src/http/server/common/save.rs | 31 +- packages/core/src/http/server/mod.rs | 197 +++-- packages/core/src/http/server/v2.rs | 67 +- packages/core/tests/v2_server.rs | 3 + packages/localsend_isolates/lib/isolate.dart | 9 +- .../lib/rust/api/server.dart | 48 +- .../lib/rust/api/server.freezed.dart | 193 +++- .../lib/rust/frb_generated.dart | 290 ++++-- .../lib/src/isolate/child/server_isolate.dart | 203 ++++- .../lib/src/isolate/parent/actions.dart | 146 +++- .../lib/src/isolate/parent/actions_sync.dart | 12 + .../lib/src/task/server/http_server.dart | 37 +- .../localsend_isolates/lib/util/rust.dart | 38 + packages/localsend_isolates/rust/Cargo.lock | 1 + .../localsend_isolates/rust/src/api/server.rs | 125 ++- .../rust/src/frb_generated.rs | 343 +++++++- 38 files changed, 2360 insertions(+), 1546 deletions(-) delete mode 100644 app/lib/provider/network/server/controller/common.dart delete mode 100644 app/lib/util/simple_server.dart diff --git a/app/android/app/src/main/kotlin/org/localsend/localsend_app/MainActivity.kt b/app/android/app/src/main/kotlin/org/localsend/localsend_app/MainActivity.kt index 9bc806cf..b10d976f 100644 --- a/app/android/app/src/main/kotlin/org/localsend/localsend_app/MainActivity.kt +++ b/app/android/app/src/main/kotlin/org/localsend/localsend_app/MainActivity.kt @@ -61,6 +61,8 @@ class MainActivity : FlutterActivity() { "getFileDescriptor" -> handleGetFileDescriptor(call, result) + "createFile" -> handleCreateFile(call, result) + "openContentUri" -> { openUri(context, call.argument("uri")!!) result.success(null) @@ -117,6 +119,63 @@ class MainActivity : FlutterActivity() { } } + /// Creates a new file inside a SAF directory and opens it for writing. + /// + /// Returns the URI of the created document (Android may rename the file on + /// collisions) and an owned writable file descriptor. The descriptor must be + /// closed by the native consumer it is passed to. + private fun handleCreateFile(call: MethodCall, result: MethodChannel.Result) { + val parentUriString = call.argument("parentUri") + val fileName = call.argument("fileName") + val mimeType = call.argument("mimeType") ?: "application/octet-stream" + if (parentUriString == null || fileName == null) { + result.error("INVALID_ARGUMENT", "Missing parentUri or fileName", null) + return + } + + try { + val parentUri = Uri.parse(parentUriString) + + // A pure tree URI (content://…/tree/X) must be converted to its + // document form before it can be used as a parent document. + val segments = parentUri.pathSegments + val parentDocumentUri = if (segments.size == 2 && segments[0] == "tree") { + DocumentsContract.buildDocumentUriUsingTree( + parentUri, + DocumentsContract.getTreeDocumentId(parentUri) + ) + } else { + parentUri + } + + val documentUri = + DocumentsContract.createDocument(contentResolver, parentDocumentUri, mimeType, fileName) + if (documentUri == null) { + result.error("CREATE_FAILED", "Could not create $fileName in $parentUriString", null) + return + } + + val parcelFileDescriptor = contentResolver.openFileDescriptor(documentUri, "w") + if (parcelFileDescriptor == null) { + result.error("OPEN_FAILED", "The content provider did not return a file descriptor", null) + return + } + + parcelFileDescriptor.use { + result.success( + mapOf( + "uri" to documentUri.toString(), + "fd" to it.detachFd(), + ) + ) + } + } catch (e: SecurityException) { + result.error("PERMISSION_DENIED", e.message ?: "Permission denied for content URI", null) + } catch (e: Exception) { + result.error("CREATE_FAILED", e.message ?: "Failed to create file", null) + } + } + private fun openDirectoryPicker(onlyPath: Boolean) { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) diff --git a/app/lib/model/state/send/web/web_send_session.dart b/app/lib/model/state/send/web/web_send_session.dart index 524ab41a..c7a129bd 100644 --- a/app/lib/model/state/send/web/web_send_session.dart +++ b/app/lib/model/state/send/web/web_send_session.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:dart_mappable/dart_mappable.dart'; part 'web_send_session.mapper.dart'; @@ -7,13 +5,13 @@ part 'web_send_session.mapper.dart'; @MappableClass() class WebSendSession with WebSendSessionMappable { final String sessionId; - final StreamController? responseHandler; // used to accept or reject incoming requests + final bool pending; // true while waiting for the user to accept or reject the request final String ip; final String deviceInfo; // parsed from userAgent const WebSendSession({ required this.sessionId, - required this.responseHandler, + required this.pending, required this.ip, required this.deviceInfo, }); diff --git a/app/lib/model/state/send/web/web_send_session.mapper.dart b/app/lib/model/state/send/web/web_send_session.mapper.dart index de3771a1..70c5ed0d 100644 --- a/app/lib/model/state/send/web/web_send_session.mapper.dart +++ b/app/lib/model/state/send/web/web_send_session.mapper.dart @@ -27,10 +27,11 @@ class WebSendSessionMapper extends ClassMapperBase { 'sessionId', _$sessionId, ); - static StreamController? _$responseHandler(WebSendSession v) => - v.responseHandler; - static const Field> - _f$responseHandler = Field('responseHandler', _$responseHandler); + static bool _$pending(WebSendSession v) => v.pending; + static const Field _f$pending = Field( + 'pending', + _$pending, + ); static String _$ip(WebSendSession v) => v.ip; static const Field _f$ip = Field('ip', _$ip); static String _$deviceInfo(WebSendSession v) => v.deviceInfo; @@ -42,7 +43,7 @@ class WebSendSessionMapper extends ClassMapperBase { @override final MappableFields fields = const { #sessionId: _f$sessionId, - #responseHandler: _f$responseHandler, + #pending: _f$pending, #ip: _f$ip, #deviceInfo: _f$deviceInfo, }; @@ -50,7 +51,7 @@ class WebSendSessionMapper extends ClassMapperBase { static WebSendSession _instantiate(DecodingData data) { return WebSendSession( sessionId: data.dec(_f$sessionId), - responseHandler: data.dec(_f$responseHandler), + pending: data.dec(_f$pending), ip: data.dec(_f$ip), deviceInfo: data.dec(_f$deviceInfo), ); @@ -118,12 +119,7 @@ extension WebSendSessionValueCopy<$R, $Out> abstract class WebSendSessionCopyWith<$R, $In extends WebSendSession, $Out> implements ClassCopyWith<$R, $In, $Out> { - $R call({ - String? sessionId, - StreamController? responseHandler, - String? ip, - String? deviceInfo, - }); + $R call({String? sessionId, bool? pending, String? ip, String? deviceInfo}); WebSendSessionCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( Then<$Out2, $R2> t, ); @@ -138,23 +134,19 @@ class _WebSendSessionCopyWithImpl<$R, $Out> late final ClassMapperBase $mapper = WebSendSessionMapper.ensureInitialized(); @override - $R call({ - String? sessionId, - Object? responseHandler = $none, - String? ip, - String? deviceInfo, - }) => $apply( - FieldCopyWithData({ - if (sessionId != null) #sessionId: sessionId, - if (responseHandler != $none) #responseHandler: responseHandler, - if (ip != null) #ip: ip, - if (deviceInfo != null) #deviceInfo: deviceInfo, - }), - ); + $R call({String? sessionId, bool? pending, String? ip, String? deviceInfo}) => + $apply( + FieldCopyWithData({ + if (sessionId != null) #sessionId: sessionId, + if (pending != null) #pending: pending, + if (ip != null) #ip: ip, + if (deviceInfo != null) #deviceInfo: deviceInfo, + }), + ); @override WebSendSession $make(CopyWithData data) => WebSendSession( sessionId: data.get(#sessionId, or: $value.sessionId), - responseHandler: data.get(#responseHandler, or: $value.responseHandler), + pending: data.get(#pending, or: $value.pending), ip: data.get(#ip, or: $value.ip), deviceInfo: data.get(#deviceInfo, or: $value.deviceInfo), ); diff --git a/app/lib/model/state/send/web/web_send_state.dart b/app/lib/model/state/send/web/web_send_state.dart index 8006480e..5988679d 100644 --- a/app/lib/model/state/send/web/web_send_state.dart +++ b/app/lib/model/state/send/web/web_send_state.dart @@ -9,19 +9,17 @@ class WebSendState with WebSendStateMappable { final Map sessions; // session id -> session data, also includes incoming requests final Map files; // file id as key final bool autoAccept; // automatically accept incoming requests - final String? pin; - final Map pinAttempts; // IP address -> attempts (will be reset on session end) + final String? pin; // the PIN is enforced by the Rust server; changing it requires a server restart const WebSendState({ required this.sessions, required this.files, required this.autoAccept, required this.pin, - required this.pinAttempts, }); @override String toString() { - return 'WebSendState(sessions: $sessions, files: <${files.keys}>, autoAccept: $autoAccept, pin: $pin, pinAttempts: $pinAttempts)'; + return 'WebSendState(sessions: $sessions, files: <${files.keys}>, autoAccept: $autoAccept, pin: $pin)'; } } diff --git a/app/lib/model/state/send/web/web_send_state.mapper.dart b/app/lib/model/state/send/web/web_send_state.mapper.dart index 3e32af73..372f5ea2 100644 --- a/app/lib/model/state/send/web/web_send_state.mapper.dart +++ b/app/lib/model/state/send/web/web_send_state.mapper.dart @@ -39,11 +39,6 @@ class WebSendStateMapper extends ClassMapperBase { ); static String? _$pin(WebSendState v) => v.pin; static const Field _f$pin = Field('pin', _$pin); - static Map _$pinAttempts(WebSendState v) => v.pinAttempts; - static const Field> _f$pinAttempts = Field( - 'pinAttempts', - _$pinAttempts, - ); @override final MappableFields fields = const { @@ -51,7 +46,6 @@ class WebSendStateMapper extends ClassMapperBase { #files: _f$files, #autoAccept: _f$autoAccept, #pin: _f$pin, - #pinAttempts: _f$pinAttempts, }; static WebSendState _instantiate(DecodingData data) { @@ -60,7 +54,6 @@ class WebSendStateMapper extends ClassMapperBase { files: data.dec(_f$files), autoAccept: data.dec(_f$autoAccept), pin: data.dec(_f$pin), - pinAttempts: data.dec(_f$pinAttempts), ); } @@ -140,13 +133,11 @@ abstract class WebSendStateCopyWith<$R, $In extends WebSendState, $Out> WebSendFileCopyWith<$R, WebSendFile, WebSendFile> > get files; - MapCopyWith<$R, String, int, ObjectCopyWith<$R, int, int>> get pinAttempts; $R call({ Map? sessions, Map? files, bool? autoAccept, String? pin, - Map? pinAttempts, }); WebSendStateCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); } @@ -184,26 +175,17 @@ class _WebSendStateCopyWithImpl<$R, $Out> (v) => call(files: v), ); @override - MapCopyWith<$R, String, int, ObjectCopyWith<$R, int, int>> get pinAttempts => - MapCopyWith( - $value.pinAttempts, - (v, t) => ObjectCopyWith(v, $identity, t), - (v) => call(pinAttempts: v), - ); - @override $R call({ Map? sessions, Map? files, bool? autoAccept, Object? pin = $none, - Map? pinAttempts, }) => $apply( FieldCopyWithData({ if (sessions != null) #sessions: sessions, if (files != null) #files: files, if (autoAccept != null) #autoAccept: autoAccept, if (pin != $none) #pin: pin, - if (pinAttempts != null) #pinAttempts: pinAttempts, }), ); @override @@ -212,7 +194,6 @@ class _WebSendStateCopyWithImpl<$R, $Out> files: data.get(#files, or: $value.files), autoAccept: data.get(#autoAccept, or: $value.autoAccept), pin: data.get(#pin, or: $value.pin), - pinAttempts: data.get(#pinAttempts, or: $value.pinAttempts), ); @override diff --git a/app/lib/model/state/server/receive_session_state.dart b/app/lib/model/state/server/receive_session_state.dart index 06a1caf2..a6ff26b9 100644 --- a/app/lib/model/state/server/receive_session_state.dart +++ b/app/lib/model/state/server/receive_session_state.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:dart_mappable/dart_mappable.dart'; import 'package:localsend_app/model/state/server/receiving_file.dart'; import 'package:localsend_isolates/model/device.dart'; @@ -43,8 +41,6 @@ class ReceiveSessionState with ReceiveSessionStateMappable implements SessionSta /// WARNING: This list is modified in-place! final Set createdDirectories; - final StreamController?>? responseHandler; - const ReceiveSessionState({ required this.sessionId, required this.status, @@ -57,7 +53,6 @@ class ReceiveSessionState with ReceiveSessionStateMappable implements SessionSta required this.cacheDirectory, required this.saveToGallery, required this.createdDirectories, - required this.responseHandler, }); /// Returns the message of this request if this is a "message request". diff --git a/app/lib/model/state/server/receive_session_state.mapper.dart b/app/lib/model/state/server/receive_session_state.mapper.dart index 40b64d33..c36cef70 100644 --- a/app/lib/model/state/server/receive_session_state.mapper.dart +++ b/app/lib/model/state/server/receive_session_state.mapper.dart @@ -75,14 +75,6 @@ class ReceiveSessionStateMapper extends ClassMapperBase { v.createdDirectories; static const Field> _f$createdDirectories = Field('createdDirectories', _$createdDirectories); - static StreamController?>? _$responseHandler( - ReceiveSessionState v, - ) => v.responseHandler; - static const Field< - ReceiveSessionState, - StreamController?> - > - _f$responseHandler = Field('responseHandler', _$responseHandler); @override final MappableFields fields = const { @@ -97,7 +89,6 @@ class ReceiveSessionStateMapper extends ClassMapperBase { #cacheDirectory: _f$cacheDirectory, #saveToGallery: _f$saveToGallery, #createdDirectories: _f$createdDirectories, - #responseHandler: _f$responseHandler, }; static ReceiveSessionState _instantiate(DecodingData data) { @@ -113,7 +104,6 @@ class ReceiveSessionStateMapper extends ClassMapperBase { cacheDirectory: data.dec(_f$cacheDirectory), saveToGallery: data.dec(_f$saveToGallery), createdDirectories: data.dec(_f$createdDirectories), - responseHandler: data.dec(_f$responseHandler), ); } @@ -207,7 +197,6 @@ abstract class ReceiveSessionStateCopyWith< String? cacheDirectory, bool? saveToGallery, Set? createdDirectories, - StreamController?>? responseHandler, }); ReceiveSessionStateCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( Then<$Out2, $R2> t, @@ -250,7 +239,6 @@ class _ReceiveSessionStateCopyWithImpl<$R, $Out> String? cacheDirectory, bool? saveToGallery, Set? createdDirectories, - Object? responseHandler = $none, }) => $apply( FieldCopyWithData({ if (sessionId != null) #sessionId: sessionId, @@ -265,7 +253,6 @@ class _ReceiveSessionStateCopyWithImpl<$R, $Out> if (cacheDirectory != null) #cacheDirectory: cacheDirectory, if (saveToGallery != null) #saveToGallery: saveToGallery, if (createdDirectories != null) #createdDirectories: createdDirectories, - if (responseHandler != $none) #responseHandler: responseHandler, }), ); @override @@ -287,7 +274,6 @@ class _ReceiveSessionStateCopyWithImpl<$R, $Out> #createdDirectories, or: $value.createdDirectories, ), - responseHandler: data.get(#responseHandler, or: $value.responseHandler), ); @override diff --git a/app/lib/model/state/server/server_state.dart b/app/lib/model/state/server/server_state.dart index df41a0fa..9e1e433a 100644 --- a/app/lib/model/state/server/server_state.dart +++ b/app/lib/model/state/server/server_state.dart @@ -1,32 +1,27 @@ import 'package:dart_mappable/dart_mappable.dart'; import 'package:localsend_app/model/state/send/web/web_send_state.dart'; import 'package:localsend_app/model/state/server/receive_session_state.dart'; -import 'package:localsend_app/util/simple_server.dart'; part 'server_state.mapper.dart'; @MappableClass() class ServerState with ServerStateMappable { - final SimpleServer httpServer; final String alias; final int port; final bool https; final ReceiveSessionState? session; final WebSendState? webSendState; - final Map pinAttempts; // IP address -> attempts (won't be reset on session end) const ServerState({ - required this.httpServer, required this.alias, required this.port, required this.https, required this.session, required this.webSendState, - required this.pinAttempts, }); @override String toString() { - return 'ServerState(alias: $alias, port: $port, https: $https, session: $session, webSendState: $webSendState, pinAttempts: $pinAttempts)'; + return 'ServerState(alias: $alias, port: $port, https: $https, session: $session, webSendState: $webSendState)'; } } diff --git a/app/lib/model/state/server/server_state.mapper.dart b/app/lib/model/state/server/server_state.mapper.dart index c33d1e1d..15e711a7 100644 --- a/app/lib/model/state/server/server_state.mapper.dart +++ b/app/lib/model/state/server/server_state.mapper.dart @@ -24,11 +24,6 @@ class ServerStateMapper extends ClassMapperBase { @override final String id = 'ServerState'; - static SimpleServer _$httpServer(ServerState v) => v.httpServer; - static const Field _f$httpServer = Field( - 'httpServer', - _$httpServer, - ); static String _$alias(ServerState v) => v.alias; static const Field _f$alias = Field('alias', _$alias); static int _$port(ServerState v) => v.port; @@ -45,32 +40,23 @@ class ServerStateMapper extends ClassMapperBase { 'webSendState', _$webSendState, ); - static Map _$pinAttempts(ServerState v) => v.pinAttempts; - static const Field> _f$pinAttempts = Field( - 'pinAttempts', - _$pinAttempts, - ); @override final MappableFields fields = const { - #httpServer: _f$httpServer, #alias: _f$alias, #port: _f$port, #https: _f$https, #session: _f$session, #webSendState: _f$webSendState, - #pinAttempts: _f$pinAttempts, }; static ServerState _instantiate(DecodingData data) { return ServerState( - httpServer: data.dec(_f$httpServer), alias: data.dec(_f$alias), port: data.dec(_f$port), https: data.dec(_f$https), session: data.dec(_f$session), webSendState: data.dec(_f$webSendState), - pinAttempts: data.dec(_f$pinAttempts), ); } @@ -137,15 +123,12 @@ abstract class ServerStateCopyWith<$R, $In extends ServerState, $Out> ReceiveSessionStateCopyWith<$R, ReceiveSessionState, ReceiveSessionState>? get session; WebSendStateCopyWith<$R, WebSendState, WebSendState>? get webSendState; - MapCopyWith<$R, String, int, ObjectCopyWith<$R, int, int>> get pinAttempts; $R call({ - SimpleServer? httpServer, String? alias, int? port, bool? https, ReceiveSessionState? session, WebSendState? webSendState, - Map? pinAttempts, }); ServerStateCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); } @@ -165,41 +148,28 @@ class _ServerStateCopyWithImpl<$R, $Out> WebSendStateCopyWith<$R, WebSendState, WebSendState>? get webSendState => $value.webSendState?.copyWith.$chain((v) => call(webSendState: v)); @override - MapCopyWith<$R, String, int, ObjectCopyWith<$R, int, int>> get pinAttempts => - MapCopyWith( - $value.pinAttempts, - (v, t) => ObjectCopyWith(v, $identity, t), - (v) => call(pinAttempts: v), - ); - @override $R call({ - SimpleServer? httpServer, String? alias, int? port, bool? https, Object? session = $none, Object? webSendState = $none, - Map? pinAttempts, }) => $apply( FieldCopyWithData({ - if (httpServer != null) #httpServer: httpServer, if (alias != null) #alias: alias, if (port != null) #port: port, if (https != null) #https: https, if (session != $none) #session: session, if (webSendState != $none) #webSendState: webSendState, - if (pinAttempts != null) #pinAttempts: pinAttempts, }), ); @override ServerState $make(CopyWithData data) => ServerState( - httpServer: data.get(#httpServer, or: $value.httpServer), alias: data.get(#alias, or: $value.alias), port: data.get(#port, or: $value.port), https: data.get(#https, or: $value.https), session: data.get(#session, or: $value.session), webSendState: data.get(#webSendState, or: $value.webSendState), - pinAttempts: data.get(#pinAttempts, or: $value.pinAttempts), ); @override diff --git a/app/lib/pages/tabs/settings_tab.dart b/app/lib/pages/tabs/settings_tab.dart index 0e63214b..8db398d7 100644 --- a/app/lib/pages/tabs/settings_tab.dart +++ b/app/lib/pages/tabs/settings_tab.dart @@ -13,6 +13,7 @@ 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/network/server/server_provider.dart'; import 'package:localsend_app/provider/settings_provider.dart'; import 'package:localsend_app/provider/version_provider.dart'; import 'package:localsend_app/util/alias_generator.dart'; @@ -195,6 +196,11 @@ class SettingsTab extends StatelessWidget { await ref.notifier(settingsProvider).setReceivePin(newPin); } } + + // The pin is enforced by the Rust server, so it needs a restart. + if (ref.read(serverProvider) != null) { + await ref.notifier(serverProvider).restartServerFromSettings(); + } }, ), if (checkPlatformWithFileSystem()) diff --git a/app/lib/pages/web_send_page.dart b/app/lib/pages/web_send_page.dart index 7f148782..2aac2fa8 100644 --- a/app/lib/pages/web_send_page.dart +++ b/app/lib/pages/web_send_page.dart @@ -43,7 +43,6 @@ class _WebSendPageState extends State with Refena { void _init({required bool encrypted}) async { final settings = ref.read(settingsProvider); - final (beforeAutoAccept, beforePin) = ref.read(serverProvider.select((state) => (state?.webSendState?.autoAccept, state?.webSendState?.pin))); setState(() { _stateEnum = _ServerState.initializing; _encrypted = encrypted; @@ -51,18 +50,15 @@ class _WebSendPageState extends State with Refena { }); await sleepAsync(500); try { + // The auto accept setting and the pin of a previous web send state are kept. await ref .notifier(serverProvider) - .restartServer( + .restartServerWithWebSend( alias: settings.alias, port: settings.port, https: _encrypted, + files: widget.files, ); - await ref.notifier(serverProvider).initializeWebSend(widget.files); - if (beforeAutoAccept != null) { - ref.notifier(serverProvider).setWebSendAutoAccept(beforeAutoAccept); - } - ref.notifier(serverProvider).setWebSendPin(beforePin); setState(() { _stateEnum = _ServerState.running; }); @@ -135,8 +131,12 @@ class _WebSendPageState extends State with Refena { ); } - final serverState = context.watch(serverProvider)!; - final webSendState = serverState.webSendState!; + final serverState = context.watch(serverProvider); + final webSendState = serverState?.webSendState; + if (serverState == null || webSendState == null) { + // the server is restarting (e.g. because the pin changed) + return const Center(child: CircularProgressIndicator()); + } final networkState = context.watch(localIpProvider); return ResponsiveListView( @@ -243,7 +243,7 @@ class _WebSendPageState extends State with Refena { Text( session.deviceInfo, style: Theme.of(context).textTheme.bodyLarge!.copyWith( - color: session.responseHandler != null ? Theme.of(context).colorScheme.warning : null, + color: session.pending ? Theme.of(context).colorScheme.warning : null, ), ), const SizedBox(height: 5), @@ -251,7 +251,7 @@ class _WebSendPageState extends State with Refena { ], ), ), - if (session.responseHandler != null) ...[ + if (session.pending) ...[ TextButton( onPressed: () { ref.notifier(serverProvider).declineWebSendRequest(session.sessionId); @@ -327,7 +327,7 @@ class _WebSendPageState extends State with Refena { onChanged: (value) async { final currentPIN = webSendState.pin; if (currentPIN != null) { - ref.notifier(serverProvider).setWebSendPin(null); + await ref.notifier(serverProvider).setWebSendPin(null); } else { final String? newPin = await showDialog( context: context, @@ -338,7 +338,7 @@ class _WebSendPageState extends State with Refena { ); if (newPin != null && newPin.isNotEmpty) { - ref.notifier(serverProvider).setWebSendPin(newPin); + await ref.notifier(serverProvider).setWebSendPin(newPin); } } }, diff --git a/app/lib/provider/network/server/controller/common.dart b/app/lib/provider/network/server/controller/common.dart deleted file mode 100644 index 55edefa5..00000000 --- a/app/lib/provider/network/server/controller/common.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'dart:io'; - -import 'package:localsend_app/provider/network/server/server_utils.dart'; -import 'package:localsend_app/util/simple_server.dart'; - -/// Responds with 401 or 429 if the pin is invalid or too many attempts. -/// Returns true if the pin is correct, or if no pin is set. -Future checkPin({ - required ServerUtils server, - required String? pin, - required Map pinAttempts, - required HttpRequest request, -}) async { - if (pin != null) { - final attempts = pinAttempts[request.ip] ?? 0; - if (attempts >= 3) { - await request.respondJson(429, message: 'Too many attempts.'); - return false; - } - - final requestPin = request.uri.queryParameters['pin']; - if (requestPin != pin) { - if (requestPin?.isNotEmpty ?? false) { - pinAttempts[request.ip] = attempts + 1; - - if (attempts == 2) { - // it was 2 before incrementing - await request.respondJson(429, message: 'Too many attempts.'); - return false; - } - } - await request.respondJson(401, message: 'Invalid pin.'); - return false; - } - } - - return true; -} diff --git a/app/lib/provider/network/server/controller/receive_controller.dart b/app/lib/provider/network/server/controller/receive_controller.dart index b5214f20..ceee1cab 100644 --- a/app/lib/provider/network/server/controller/receive_controller.dart +++ b/app/lib/provider/network/server/controller/receive_controller.dart @@ -1,12 +1,9 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:collection/collection.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:localsend_app/model/state/send/send_session_state.dart'; import 'package:localsend_app/model/state/server/receive_session_state.dart'; import 'package:localsend_app/model/state/server/receiving_file.dart'; import 'package:localsend_app/pages/home_page.dart'; @@ -19,11 +16,11 @@ import 'package:localsend_app/provider/http_provider.dart'; import 'package:localsend_app/provider/logging/discovery_logs_provider.dart'; import 'package:localsend_app/provider/network/nearby_devices_provider.dart'; import 'package:localsend_app/provider/network/send_provider.dart'; -import 'package:localsend_app/provider/network/server/controller/common.dart'; import 'package:localsend_app/provider/network/server/server_provider.dart'; import 'package:localsend_app/provider/network/server/server_utils.dart'; import 'package:localsend_app/provider/progress_provider.dart'; import 'package:localsend_app/provider/receive_history_provider.dart'; +import 'package:localsend_app/provider/security_provider.dart'; import 'package:localsend_app/provider/selection/selected_receiving_files_provider.dart'; import 'package:localsend_app/provider/selection/selected_sending_files_provider.dart'; import 'package:localsend_app/provider/settings_provider.dart'; @@ -31,19 +28,13 @@ import 'package:localsend_app/util/native/directories.dart'; import 'package:localsend_app/util/native/file_saver.dart'; import 'package:localsend_app/util/native/platform_check.dart'; import 'package:localsend_app/util/native/tray_helper.dart'; -import 'package:localsend_app/util/simple_server.dart'; import 'package:localsend_app/widget/dialogs/open_file_dialog.dart'; -import 'package:localsend_isolates/api_route_builder.dart'; -import 'package:localsend_isolates/constants.dart'; +import 'package:localsend_isolates/isolate.dart'; import 'package:localsend_isolates/model/device.dart'; -import 'package:localsend_isolates/model/dto/info_dto.dart'; -import 'package:localsend_isolates/model/dto/info_register_dto.dart'; -import 'package:localsend_isolates/model/dto/prepare_upload_request_dto.dart'; -import 'package:localsend_isolates/model/dto/prepare_upload_response_dto.dart'; -import 'package:localsend_isolates/model/dto/register_dto.dart'; import 'package:localsend_isolates/model/file_status.dart'; import 'package:localsend_isolates/model/file_type.dart'; import 'package:localsend_isolates/model/session_status.dart'; +import 'package:localsend_isolates/rust/api/server.dart' show SessionEndReasonV2; import 'package:localsend_isolates/util/rust.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -52,192 +43,58 @@ import 'package:routerino/routerino.dart'; import 'package:uuid/uuid.dart'; import 'package:window_manager/window_manager.dart'; -const _uuid = Uuid(); - final _logger = Logger('ReceiveController'); -/// Handles all requests for receiving files. +/// Handles all server events for receiving files. +/// The HTTP requests themselves are served by the Rust server which emits +/// the events handled here. class ReceiveController { final ServerUtils server; ReceiveController(this.server); - /// Installs all routes for receiving files. - void installRoutes({ - required SimpleServerRouteBuilder router, - required String alias, - required int port, - required bool https, - required String fingerprint, - required String showToken, - }) { - router.get(ApiRoute.info.v1, (HttpRequest request) async { - return await _infoHandler(request: request, alias: alias, fingerprint: fingerprint); - }); - - router.get(ApiRoute.info.v2, (HttpRequest request) async { - return await _infoHandler(request: request, alias: alias, fingerprint: fingerprint); - }); - - // An upgraded version of /info - router.post(ApiRoute.register.v1, (HttpRequest request) async { - return await _registerHandler(request: request, alias: alias, port: port, https: https, fingerprint: fingerprint); - }); - - router.post(ApiRoute.register.v2, (HttpRequest request) async { - return await _registerHandler(request: request, alias: alias, port: port, https: https, fingerprint: fingerprint); - }); - - router.post(ApiRoute.prepareUpload.v1, (HttpRequest request) async { - return await _prepareUploadHandler(request: request, port: port, https: https, v2: false); - }); - - router.post(ApiRoute.prepareUpload.v2, (HttpRequest request) async { - return await _prepareUploadHandler(request: request, port: port, https: https, v2: true); - }); - - router.post(ApiRoute.upload.v1, (HttpRequest request) async { - return await _uploadHandler(request: request, v2: false); - }); - - router.post(ApiRoute.upload.v2, (HttpRequest request) async { - return await _uploadHandler(request: request, v2: true); - }); - - router.post(ApiRoute.cancel.v1, (HttpRequest request) async { - return await _cancelHandler(request: request, v2: false); - }); - - router.post(ApiRoute.cancel.v2, (HttpRequest request) async { - return await _cancelHandler(request: request, v2: true); - }); - - router.post(ApiRoute.show.v1, (HttpRequest request) async { - return await _showHandler(request: request, showToken: showToken); - }); - - router.post(ApiRoute.show.v2, (HttpRequest request) async { - return await _showHandler(request: request, showToken: showToken); - }); - } - - Future _infoHandler({ - required HttpRequest request, - required String alias, - required String fingerprint, - }) async { - final senderFingerprint = request.uri.queryParameters['fingerprint']; - if (senderFingerprint == fingerprint) { + /// A device registered itself on this server. + Future onRegister(HttpServerRegisterEvent event) async { + if (event.info.fingerprint == server.ref.read(securityProvider).certificateHash) { // "I talked to myself lol" - return await request.respondJson(412, message: 'Self-discovered'); - } - - final deviceInfo = server.ref.read(deviceInfoProvider); - - final dto = InfoDto( - alias: alias, - version: protocolVersion, - deviceModel: deviceInfo.deviceModel, - deviceType: deviceInfo.deviceType, - fingerprint: fingerprint, - download: server.getState().webSendState != null, - ); - - return await request.respondJson(200, body: dto.toJson()); - } - - Future _registerHandler({ - required HttpRequest request, - required String alias, - required String fingerprint, - required int port, - required bool https, - }) async { - final payload = await request.readAsString(); - final RegisterDto requestDto; - try { - requestDto = RegisterDto.fromJson(jsonDecode(payload)); - } catch (e) { - return await request.respondJson(400, message: 'Request body malformed'); - } - - if (requestDto.fingerprint == fingerprint) { - // "I talked to myself lol" - return await request.respondJson(412, message: 'Self-discovered'); - } - - // Save device information - await server.ref - .redux(nearbyDevicesProvider) - .dispatchAsync(RegisterDeviceAction(requestDto.toDevice(request.ip, port, https, HttpDiscovery(ip: request.ip)))); - server.ref.notifier(discoveryLoggerProvider).addLog('[DISCOVER/TCP] Received "/register" HTTP request: ${requestDto.alias} (${request.ip})'); - - final deviceInfo = server.ref.read(deviceInfoProvider); - - final responseDto = InfoDto( - alias: alias, - version: protocolVersion, - deviceModel: deviceInfo.deviceModel, - deviceType: deviceInfo.deviceType, - fingerprint: fingerprint, - download: server.getState().webSendState != null, - ); - - return await request.respondJson(200, body: responseDto.toJson()); - } - - Future _prepareUploadHandler({ - required HttpRequest request, - required int port, - required bool https, - required bool v2, - }) async { - if (server.getState().session != null) { - // block incoming requests when we are already in a session - return await request.respondJson(409, message: 'Blocked by another session'); - } - - final pinCorrect = await checkPin( - server: server, - pin: server.ref.read(settingsProvider).receivePin, - pinAttempts: server.getState().pinAttempts, - request: request, - ); - if (!pinCorrect) { return; } - final PrepareUploadRequestDto dto; - try { - final payload = await request.readAsString(); - dto = PrepareUploadRequestDto.fromJson(jsonDecode(payload)); - } catch (e) { - return await request.respondJson(400, message: 'Request body malformed'); - } + await server.ref.redux(nearbyDevicesProvider).dispatchAsync(RegisterDeviceAction(event.info.toDevice(event.ip, HttpDiscovery(ip: event.ip)))); + server.ref.notifier(discoveryLoggerProvider).addLog('[DISCOVER/TCP] Received "/register" HTTP request: ${event.info.alias} (${event.ip})'); + } - if (dto.files.isEmpty) { - // block empty requests (at least one file is required) - return await request.respondJson(400, message: 'Request must contain at least one file'); + /// A sender requests to upload files. + /// The Rust server already checked the PIN and enforces that only one + /// session can be active at a time. + Future onPrepareUpload(HttpServerPrepareUploadEvent event) async { + if (server.getStateOrNull()?.session != null) { + // The Rust server is the authority on the single-session invariant: + // a new request means the old session is over (e.g. finished but still + // displayed, or aborted while waiting). + closeSession(); } final settings = server.ref.read(settingsProvider); final destinationDir = settings.destination ?? await getDefaultDestinationDirectory(); final cacheDir = await getCacheDirectory(); - final sessionId = _uuid.v4(); + final sessionId = event.sessionId; + final files = { + for (final entry in event.files.entries) entry.key: entry.value.toDart(), + }; _logger.info('Session Id: $sessionId'); _logger.info('Destination Directory: $destinationDir'); - final streamController = StreamController?>(); server.setState( (oldState) => oldState?.copyWith( session: ReceiveSessionState( sessionId: sessionId, status: SessionStatus.waiting, - sender: dto.info.toDevice(request.ip, port, https, null), - senderAlias: server.ref.read(favoritesProvider).firstWhereOrNull((e) => e.fingerprint == dto.info.fingerprint)?.alias ?? dto.info.alias, + sender: event.info.toDevice(event.ip, null), + senderAlias: server.ref.read(favoritesProvider).firstWhereOrNull((e) => e.fingerprint == event.info.fingerprint)?.alias ?? event.info.alias, files: { - for (final file in dto.files.values) + for (final file in files.values) file.id: ReceivingFile( file: file, status: FileStatus.queue, @@ -252,9 +109,8 @@ class ReceiveController { endTime: null, destinationDirectory: destinationDir, cacheDirectory: cacheDir, - saveToGallery: checkPlatformWithGallery() && settings.saveToGallery && dto.files.values.every((f) => !f.fileName.contains('/')), + saveToGallery: checkPlatformWithGallery() && settings.saveToGallery && files.values.every((f) => !f.fileName.contains('/')), createdDirectories: {}, - responseHandler: streamController, ), ), ); @@ -262,137 +118,18 @@ class ReceiveController { bool quickSave = settings.quickSave && server.getState().session?.message == null; final quickSaveFromFavorites = settings.quickSaveFromFavorites && server.getState().session?.message == null; if (quickSaveFromFavorites) { - final bool isFavorite = server.ref.read(favoritesProvider).any((e) => e.fingerprint == dto.info.fingerprint); + final bool isFavorite = server.ref.read(favoritesProvider).any((e) => e.fingerprint == event.info.fingerprint); if (isFavorite) { quickSave = true; } } - final Map? selection; + if (quickSave) { // accept all files - selection = { - for (final f in dto.files.values) f.id: f.fileName, - }; - } else { - if (checkPlatformHasTray() && (await windowManager.isMinimized() || !(await windowManager.isVisible()) || !(await windowManager.isFocused()))) { - await showFromTray(); - } - - final message = server.getState().session?.message; - if (message != null) { - // Message already received - await server.ref - .redux(receiveHistoryProvider) - .dispatchAsync( - AddHistoryEntryAction( - entryId: const Uuid().v4(), - fileName: message, - fileType: FileType.text, - path: null, - savedToGallery: false, - isMessage: true, - fileSize: utf8.encode(message).length, - senderAlias: server.getState().session!.senderAlias, - timestamp: DateTime.now().toUtc(), - ), - ); - } - - final receiveProvider = ViewProvider((ref) { - final session = ref.watch(serverProvider.select((state) => state?.session)); - return ReceivePageVm( - status: session?.status, - sender: session?.sender ?? Device.empty, - showSenderInfo: true, - files: session?.files.values.map((f) => f.file).toList() ?? [], - message: message, - onAccept: () async { - if (message != null) { - // accept nothing - ref.notifier(serverProvider).acceptFileRequest({}); - return; - } - - final sessionId = ref.read(serverProvider)?.session?.sessionId; - if (sessionId == null) { - return; - } - - final selectedFiles = ref.read(selectedReceivingFilesProvider); - ref.notifier(serverProvider).acceptFileRequest(selectedFiles); - - await Routerino.context.pushAndRemoveUntilImmediately( - removeUntil: ReceivePage, - builder: () => ProgressPage( - showAppBar: false, - closeSessionOnClose: true, - sessionId: sessionId, - ), - ); - }, - onDecline: () { - ref.notifier(serverProvider).declineFileRequest(); - }, - onClose: () { - ref.notifier(serverProvider).closeSession(); - }, - ); + await acceptFileRequest({ + for (final f in files.values) f.id: f.fileName, }); - // ignore: use_build_context_synchronously, unawaited_futures - Routerino.context.push(() => ReceivePage(receiveProvider)); - - // Delayed response (waiting for user's decision) - selection = await streamController.stream.first; - } - - if (server.getState().session == null) { - // somehow this state is already disposed - return await request.respondJson(500, message: 'Server is in invalid state'); - } - - if (selection == null) { - closeSession(); - return await request.respondJson(403, message: 'File request declined by recipient'); - } - - if (selection.isEmpty) { - // nothing selected, send this to sender and close session - // This usually happens for message transfers - closeSession(); - return await request.respondJson(204); - } - - server.setState( - (oldState) { - final receiveState = oldState!.session!; - return oldState.copyWith( - session: receiveState.copyWith( - status: SessionStatus.sending, - files: Map.fromEntries( - receiveState.files.values.map((entry) { - final desiredName = selection![entry.file.id]; - return MapEntry( - entry.file.id, - ReceivingFile( - file: entry.file, - status: desiredName != null ? FileStatus.queue : FileStatus.skipped, - token: desiredName != null ? _uuid.v4() : null, - desiredName: desiredName, - path: null, - savedToGallery: false, - errorMessage: null, - ), - ); - }), - ), - responseHandler: null, - ), - ); - }, - ); - - if (quickSave) { // ignore: use_build_context_synchronously, unawaited_futures Routerino.context.pushImmediately( () => ProgressPage( @@ -401,82 +138,99 @@ class ReceiveController { sessionId: sessionId, ), ); + return; } - final files = { - for (final file in server.getState().session!.files.values.where((f) => f.token != null)) file.file.id: file.token, - }; - - if (checkPlatform([TargetPlatform.android, TargetPlatform.iOS])) { - if (checkPlatform([TargetPlatform.android]) && !server.getState().session!.destinationDirectory.startsWith('/storage/emulated/0/Download')) { - // Android requires more permission to save files outside of the Download directory - try { - final result = await Permission.storage.request(); - _logger.info('storage permission: $result'); - } catch (e) { - _logger.warning('Could not request storage permission', e); - } - } - try { - await Permission.storage.request(); - } catch (e) { - _logger.warning('Could not request storage permission', e); - } + if (checkPlatformHasTray() && (await windowManager.isMinimized() || !(await windowManager.isVisible()) || !(await windowManager.isFocused()))) { + await showFromTray(); } - if (v2) { - return await request.respondJson( - 200, - body: PrepareUploadResponseDto( - sessionId: sessionId, - files: files.cast(), - ).toJson(), + final message = server.getState().session?.message; + if (message != null) { + // Message already received + await server.ref + .redux(receiveHistoryProvider) + .dispatchAsync( + AddHistoryEntryAction( + entryId: const Uuid().v4(), + fileName: message, + fileType: FileType.text, + path: null, + savedToGallery: false, + isMessage: true, + fileSize: utf8.encode(message).length, + senderAlias: server.getState().session!.senderAlias, + timestamp: DateTime.now().toUtc(), + ), + ); + } + + final receiveProvider = ViewProvider((ref) { + final session = ref.watch(serverProvider.select((state) => state?.session)); + return ReceivePageVm( + status: session?.status, + sender: session?.sender ?? Device.empty, + showSenderInfo: true, + files: session?.files.values.map((f) => f.file).toList() ?? [], + message: message, + onAccept: () async { + if (message != null) { + // accept nothing + await ref.notifier(serverProvider).acceptFileRequest({}); + return; + } + + final sessionId = ref.read(serverProvider)?.session?.sessionId; + if (sessionId == null) { + return; + } + + final selectedFiles = ref.read(selectedReceivingFilesProvider); + await ref.notifier(serverProvider).acceptFileRequest(selectedFiles); + + // ignore: use_build_context_synchronously + await Routerino.context.pushAndRemoveUntilImmediately( + removeUntil: ReceivePage, + builder: () => ProgressPage( + showAppBar: false, + closeSessionOnClose: true, + sessionId: sessionId, + ), + ); + }, + onDecline: () { + ref.notifier(serverProvider).declineFileRequest(); + }, + onClose: () { + ref.notifier(serverProvider).closeSession(); + }, ); - } + }); - return await request.respondJson(200, body: files); + // ignore: use_build_context_synchronously, unawaited_futures + Routerino.context.push(() => ReceivePage(receiveProvider)); } - Future _uploadHandler({ - required HttpRequest request, - required bool v2, - }) async { - final receiveState = server.getState().session; - if (receiveState == null) { - return await request.respondJson(409, message: 'No session'); - } - - if (request.ip != receiveState.sender.ip) { - _logger.warning('Invalid ip address: ${request.ip} (expected: ${receiveState.sender.ip})'); - return await request.respondJson(403, message: 'Invalid IP address: ${request.ip}'); - } - + /// An accepted file is being uploaded. + /// The Rust server already validated the session, the file token and the + /// sender's IP address; the file content is written by the Rust server to + /// the target resolved here. + Future onFileUpload(HttpServerFileUploadEvent event) async { + final receiveState = server.getStateOrNull()?.session; const allowedStates = {SessionStatus.sending, SessionStatus.finishedWithErrors}; - if (!allowedStates.contains(receiveState.status)) { - _logger.warning('Wrong state: ${receiveState.status}'); - return await request.respondJson(409, message: 'Recipient is in wrong state'); - } - - final fileId = request.uri.queryParameters['fileId']; - final token = request.uri.queryParameters['token']; - final sessionId = request.uri.queryParameters['sessionId']; - if (fileId == null || token == null || (v2 && sessionId == null)) { - // reject because of missing parameters - _logger.warning('Missing parameters: fileId=$fileId, token=$token, sessionId=$sessionId'); - return await request.respondJson(400, message: 'Missing parameters'); - } - - if (v2 && sessionId != receiveState.sessionId) { - // reject because of wrong session id - _logger.warning('Wrong session id: $sessionId (expected: ${receiveState.sessionId})'); - return await request.respondJson(403, message: 'Invalid session id'); + if (receiveState == null || receiveState.sessionId != event.sessionId || !allowedStates.contains(receiveState.status)) { + _logger.warning('Rejecting upload of file ${event.fileId}: no matching active session'); + // Reject the upload (and any further ones) by cancelling the session on the Rust side. + server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerCancelSessionAction(sessionId: event.sessionId)); + return; } + final fileId = event.fileId; final receivingFile = receiveState.files[fileId]; - if (receivingFile == null || receivingFile.token != token) { - // reject because there is no file or token does not match - _logger.warning('Wrong fileId: $fileId (expected: ${receivingFile?.file.id})'); - return await request.respondJson(403, message: 'Invalid token'); + if (receivingFile == null || receivingFile.desiredName == null) { + _logger.warning('Unexpected fileId: $fileId'); + server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerCancelSessionAction(sessionId: event.sessionId)); + return; } // begin of actual file transfer @@ -503,30 +257,57 @@ class ReceiveController { try { _logger.info('Saving ${receivingFile.file.fileName}'); - (savedToGallery, filePath) = await saveFile( + final target = await prepareFileSaveTarget( destinationDirectory: receiveState.destinationDirectory, fileName: receivingFile.desiredName!, saveToGallery: shouldSaveToGallery, isImage: fileType == FileType.image, - stream: request, - onProgress: (savedBytes) { - if (receivingFile.file.size != 0) { - server.ref - .notifier(progressProvider) - .setProgress( - sessionId: receiveState.sessionId, - fileId: fileId, - progress: savedBytes / receivingFile.file.size, - ); - } - }, + createdDirectories: receiveState.createdDirectories, + androidSdkInt: server.ref.read(deviceInfoProvider).androidSdkInt, + ); + + // The Rust server writes the file and reports the progress. + await server.ref + .redux(parentIsolateProvider) + .dispatchTakeResult( + IsolateHttpServerFileUploadTargetAction( + sessionId: event.sessionId, + fileId: fileId, + path: target.path, + fileDescriptor: target.fileDescriptor, + fileSize: receivingFile.file.size, + onProgress: (progress) { + server.ref + .notifier(progressProvider) + .setProgress( + sessionId: receiveState.sessionId, + fileId: fileId, + progress: progress, + ); + }, + ), + ); + + await applyFileTimestamps( + target: target, lastModified: receivingFile.file.metadata?.lastModified, lastAccessed: receivingFile.file.metadata?.lastAccessed, - androidSdkInt: server.ref.read(deviceInfoProvider).androidSdkInt, - createdDirectories: receiveState.createdDirectories, ); - if (server.getState().session == null || !allowedStates.contains(server.getState().session!.status)) { - return await request.respondJson(500, message: 'Server is in invalid state'); + + if (shouldSaveToGallery) { + (savedToGallery, filePath) = await saveCachedFileToGallery( + cachedPath: target.displayPath, + destinationDirectory: receiveState.destinationDirectory, + fileName: receivingFile.desiredName!, + isImage: fileType == FileType.image, + createdDirectories: receiveState.createdDirectories, + ); + } else { + filePath = target.displayPath; + } + + if (server.getStateOrNull()?.session == null || !allowedStates.contains(server.getState().session!.status)) { + return; } server.setState( (oldState) => oldState?.copyWith( @@ -571,6 +352,14 @@ class ReceiveController { ), ); _logger.severe('Failed to save file', e, st); + + // If the failure happened before the upload target was dispatched + // (e.g. preparing the save target failed), the Rust server is still + // waiting for it and the sender's request would hang forever. + // Rejecting fails the request; a no-op if the target was already sent. + server.ref + .redux(parentIsolateProvider) + .dispatch(IsolateHttpServerRejectFileUploadAction(sessionId: event.sessionId, fileId: fileId)); } server.ref @@ -581,9 +370,9 @@ class ReceiveController { progress: 1, ); - final session = server.getState().session; + final session = server.getStateOrNull()?.session; if (session == null) { - return await request.respondJson(500, message: 'Server is in invalid state'); + return; } if (allowedStates.contains(session.status) && session.files.values.map((e) => e.status).isFinishedOrError) { @@ -600,14 +389,13 @@ class ReceiveController { bool quickSave = settings.quickSave && server.getState().session?.message == null; final quickSaveFromFavorites = settings.quickSaveFromFavorites && server.getState().session?.message == null; if (quickSaveFromFavorites) { - // dto is not defined here. I must check sender fingerprint final bool isFavorite = server.ref.read(favoritesProvider).any((e) => e.fingerprint == session.sender.fingerprint); if (isFavorite) { quickSave = true; } } if (quickSave) { - // close the session **after** return of the response + // close the session **after** the response has been sent Future.delayed(Duration.zero, () { closeSession(); _logger.info('Closing session'); @@ -629,141 +417,155 @@ class ReceiveController { } _logger.info('Received all files.'); } - - return server.getState().session?.files[fileId]?.status == FileStatus.finished - ? await request.respondJson(200) - : await request.respondJson(500, message: 'Could not save file. Check receiving device for more information.'); } - Future _cancelHandler({ - required HttpRequest request, - required bool v2, - }) async { - final receiveSession = server.getState().session; - if (receiveSession != null) { - // We are currently receiving files. - - if (!v2 && receiveSession.sender.version != '1.0') { - // disallow v1 cancel for active v2 sessions - return await request.respondJson(403, message: 'No permission'); - } - - if (receiveSession.sender.ip != request.ip) { - return await request.respondJson(403, message: 'No permission'); - } - - // require session id for v2 - // don't require it when during waiting state - if (v2 && receiveSession.status != SessionStatus.waiting) { - final sessionId = request.uri.queryParameters['sessionId']; - if (sessionId != receiveSession.sessionId) { - return await request.respondJson(403, message: 'No permission'); - } - } - - // check if valid state - final currentStatus = receiveSession.status; - if (currentStatus != SessionStatus.waiting && currentStatus != SessionStatus.sending) { - return await request.respondJson(403, message: 'No permission'); - } - - _cancelBySender(server); - return await request.respondJson(200); - } else { - // We are not receiving files so we may be sending files. - - final sessionId = request.uri.queryParameters['sessionId']; - final sendSessions = server.ref.read(sendProvider); - final SendSessionState sendState; - if (v2) { - // In v2, we require sessionId. - - final selectedSession = sendSessions.values.firstWhereOrNull((s) => s.remoteSessionId == sessionId); - if (selectedSession == null) { - return await request.respondJson(403, message: 'No permission'); - } - - sendState = selectedSession; - } else { - // In v1, we are a little bit more tolerant. - // Let's assume the sessionId if only one send session exist - - final onlySession = sendSessions.values.singleOrNull; - if (onlySession == null) { - return await request.respondJson(403, message: 'No permission'); - } - - sendState = onlySession; - } - - if (sendState.target.ip != request.ip) { - return await request.respondJson(403, message: 'No permission'); - } - - // check if valid state - if (sendState.status != SessionStatus.sending) { - return await request.respondJson(403, message: 'No permission'); - } - - server.ref - .notifier(sendProvider) - .cancelSessionByReceiver( - sendState.sessionId, - ); - return await request.respondJson(200); - } - } - - Future _showHandler({ - required HttpRequest request, - required String showToken, - }) async { - final senderToken = request.uri.queryParameters['token']; - if (senderToken == showToken && checkPlatformIsDesktop()) { - // ignore: unawaited_futures - showFromTray().catchError((e) { - // don't wait for it - _logger.severe('Failed to show from tray', e); - }); - - // ignore: unawaited_futures - request.readAsString().then((body) async { - if (body.isEmpty) { - return; - } - - final Map jsonBody = jsonDecode(body); - final List args = (jsonBody['args'] as List?)?.cast() ?? []; - final filesAdded = await server.ref.redux(selectedSendingFilesProvider).dispatchAsyncTakeResult(LoadSelectionFromArgsAction(args)); - if (filesAdded) { - server.ref.redux(homePageControllerProvider).dispatch(ChangeTabAction(HomeTab.send)); - } - }); - - return await request.respondJson(200); - } - - return await request.respondJson(403, message: 'Invalid token'); - } - - void acceptFileRequest(Map fileNameMap) { - final controller = server.getState().session?.responseHandler; - if (controller == null || controller.isClosed) { + /// An upload session ended on the Rust server. + void onSessionEnd(HttpServerSessionEndEvent event) { + final receiveSession = server.getStateOrNull()?.session; + if (receiveSession == null || receiveSession.sessionId != event.sessionId) { return; } - controller.add(fileNameMap); - controller.close(); // ignore: discarded_futures + switch (event.reason) { + case SessionEndReasonV2.finished: + // Already handled when the last file finished. + break; + case SessionEndReasonV2.cancelled: + _cancelBySender(server); + } + } + + /// The sender aborted the request while the user was still deciding. + void onPrepareUploadAborted(HttpServerPrepareUploadAbortedEvent event) { + final receiveSession = server.getStateOrNull()?.session; + if (receiveSession == null || receiveSession.sessionId != event.sessionId || receiveSession.status != SessionStatus.waiting) { + return; + } + + _cancelBySender(server); + } + + /// A remote device cancels a transfer this application is currently + /// *sending* to it. + void onCancelReceived(HttpServerCancelReceivedEvent event) { + final sendSessions = server.ref.read(sendProvider); + final selectedSession = sendSessions.values.firstWhereOrNull((s) => s.remoteSessionId == event.sessionId); + if (selectedSession == null) { + return; + } + + if (selectedSession.target.ip != event.ip) { + return; + } + + if (selectedSession.status != SessionStatus.sending) { + return; + } + + server.ref + .notifier(sendProvider) + .cancelSessionByReceiver( + selectedSession.sessionId, + ); + } + + /// Another application instance requested the running application to show itself. + /// The show token has already been checked by the Rust server. + void onShow(HttpServerShowEvent event) { + if (!checkPlatformIsDesktop()) { + return; + } + + // ignore: discarded_futures + showFromTray().catchError((e) { + // don't wait for it + _logger.severe('Failed to show from tray', e); + }); + + final args = event.args; + if (args.isEmpty) { + return; + } + + // ignore: unawaited_futures, discarded_futures + server.ref.redux(selectedSendingFilesProvider).dispatchAsyncTakeResult(LoadSelectionFromArgsAction(args)).then((filesAdded) { + if (filesAdded) { + server.ref.redux(homePageControllerProvider).dispatch(ChangeTabAction(HomeTab.send)); + } + }); + } + + /// Accepts the file request with the given [fileNameMap] (file id -> desired file name). + Future acceptFileRequest(Map fileNameMap) async { + final session = server.getStateOrNull()?.session; + if (session == null || session.status != SessionStatus.waiting) { + return; + } + + if (fileNameMap.isEmpty) { + // nothing selected, the Rust server responds with 204 and creates no session + // This usually happens for message transfers + server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerPrepareUploadDecisionAction(acceptedFileIds: [])); + closeSession(); + return; + } + + server.setState( + (oldState) { + final receiveState = oldState!.session!; + return oldState.copyWith( + session: receiveState.copyWith( + status: SessionStatus.sending, + files: Map.fromEntries( + receiveState.files.values.map((entry) { + final desiredName = fileNameMap[entry.file.id]; + return MapEntry( + entry.file.id, + ReceivingFile( + file: entry.file, + status: desiredName != null ? FileStatus.queue : FileStatus.skipped, + token: null, + desiredName: desiredName, + path: null, + savedToGallery: false, + errorMessage: null, + ), + ); + }), + ), + ), + ); + }, + ); + + if (checkPlatform([TargetPlatform.android, TargetPlatform.iOS])) { + if (checkPlatform([TargetPlatform.android]) && !session.destinationDirectory.startsWith('/storage/emulated/0/Download')) { + // Android requires more permission to save files outside of the Download directory + try { + final result = await Permission.storage.request(); + _logger.info('storage permission: $result'); + } catch (e) { + _logger.warning('Could not request storage permission', e); + } + } + try { + await Permission.storage.request(); + } catch (e) { + _logger.warning('Could not request storage permission', e); + } + } + + server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerPrepareUploadDecisionAction(acceptedFileIds: fileNameMap.keys.toList())); } void declineFileRequest() { - final controller = server.getState().session?.responseHandler; - if (controller == null || controller.isClosed) { + final session = server.getStateOrNull()?.session; + if (session == null || session.status != SessionStatus.waiting) { return; } - controller.add(null); - controller.close(); // ignore: discarded_futures + server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerPrepareUploadDecisionAction(acceptedFileIds: null)); + closeSession(); } /// Updates the destination directory for the current session. @@ -789,7 +591,7 @@ class ReceiveController { } /// In addition to [closeSession], this method also - /// - cancels incoming requests (TODO) + /// - cancels the session on the Rust server so that further uploads are rejected /// - notifies the sender that the session has been canceled void cancelSession() async { final session = server.getStateOrNull()?.session; @@ -798,6 +600,9 @@ class ReceiveController { return; } + // reject further uploads + server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerCancelSessionAction(sessionId: session.sessionId)); + // notify sender final target = session.sender; try { @@ -816,9 +621,6 @@ class ReceiveController { } closeSession(); - - // TODO: cancel incoming requests (https://github.com/dart-lang/shelf/issues/319) - // restartServer(alias: tempState.alias, port: tempState.port); } void closeSession() { @@ -837,7 +639,7 @@ class ReceiveController { } void _cancelBySender(ServerUtils server) { - final receiveSession = server.getState().session; + final receiveSession = server.getStateOrNull()?.session; if (receiveSession == null) { return; } diff --git a/app/lib/provider/network/server/controller/send_controller.dart b/app/lib/provider/network/server/controller/send_controller.dart index 29dc6f25..d25b8428 100644 --- a/app/lib/provider/network/server/controller/send_controller.dart +++ b/app/lib/provider/network/server/controller/send_controller.dart @@ -1,260 +1,56 @@ -import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:localsend_app/gen/assets.gen.dart'; -import 'package:localsend_app/gen/strings.g.dart'; import 'package:localsend_app/model/cross_file.dart'; import 'package:localsend_app/model/state/send/web/web_send_file.dart'; import 'package:localsend_app/model/state/send/web/web_send_session.dart'; import 'package:localsend_app/model/state/send/web/web_send_state.dart'; -import 'package:localsend_app/provider/device_info_provider.dart'; -import 'package:localsend_app/provider/network/server/controller/common.dart'; import 'package:localsend_app/provider/network/server/server_utils.dart'; import 'package:localsend_app/provider/settings_provider.dart'; -import 'package:localsend_app/util/simple_server.dart'; -import 'package:localsend_isolates/api_route_builder.dart'; -import 'package:localsend_isolates/constants.dart'; +import 'package:localsend_app/util/native/directories.dart'; +import 'package:localsend_app/util/user_agent_analyzer.dart'; +import 'package:localsend_isolates/isolate.dart'; import 'package:localsend_isolates/model/dto/file_dto.dart'; -import 'package:localsend_isolates/model/dto/info_dto.dart'; -import 'package:localsend_isolates/model/dto/receive_request_response_dto.dart'; import 'package:localsend_isolates/model/file_type.dart'; -import 'package:localsend_isolates/util/stream.dart'; -import 'package:uri_content/uri_content.dart'; +import 'package:localsend_isolates/util/android_channel.dart' as isolate_android_channel; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as p; import 'package:uuid/uuid.dart'; const _uuid = Uuid(); -/// Handles all requests for sending files. +final _logger = Logger('WebSendController'); + +/// Handles all server events for web send (sending files to web browsers). +/// The web page and the downloads themselves are served by the Rust server +/// which emits the events handled here. class SendController { final ServerUtils server; SendController(this.server); - /// Installs all routes for receiving files. - void installRoutes({ - required SimpleServerRouteBuilder router, - required String alias, - required String fingerprint, - }) { - router.get('/', (HttpRequest request) async { - final state = server.getState(); - if (state.webSendState == null) { - // There is no web send state - return await request.respondAsset(403, Assets.web.error403); - } + /// Builds the [WebSendState] for the given [files]. + /// Files that only exist in memory (e.g. text messages) are materialized + /// to the cache directory so the Rust server can stream them. + Future buildWebSendState({required List files}) async { + final currentWebSendState = server.getStateOrNull()?.webSendState; - return await request.respondAsset(200, Assets.web.index); - }); - - router.get('/main.js', (HttpRequest request) async { - final state = server.getState(); - if (state.webSendState == null) { - // There is no web send state - return await request.respondAsset(403, Assets.web.error403); - } - - return await request.respondAsset(200, Assets.web.main, 'text/javascript; charset=utf-8'); - }); - - router.get('/i18n.json', (HttpRequest request) async { - final state = server.getState(); - if (state.webSendState == null) { - // There is no web send state - return await request.respondJson(403, message: 'Web send not initialized.'); - } - - return await request.respondJson( - 200, - body: { - 'waiting': t.web.waiting, - 'enterPin': t.web.enterPin, - 'invalidPin': t.web.invalidPin, - 'tooManyAttempts': t.web.tooManyAttempts, - 'rejected': t.web.rejected, - 'files': t.web.files, - 'fileName': t.web.fileName, - 'size': t.web.size, - }, - ); - }); - - router.post(ApiRoute.prepareDownload.v2, (HttpRequest request) async { - final state = server.getState(); - if (state.webSendState == null) { - // There is no web send state - return request.respondJson(403, message: 'Web send not initialized.'); - } - - final requestSessionId = request.uri.queryParameters['sessionId']; - if (requestSessionId != null) { - // Check if the user already has permission - final session = server.getState().webSendState?.sessions[requestSessionId]; - if (session != null && session.responseHandler == null && session.ip == request.ip) { - final deviceInfo = server.ref.read(deviceInfoProvider); - return await request.respondJson( - 200, - body: ReceiveRequestResponseDto( - info: InfoDto( - alias: alias, - version: protocolVersion, - deviceModel: deviceInfo.deviceModel, - deviceType: deviceInfo.deviceType, - fingerprint: fingerprint, - download: true, - ), - sessionId: session.sessionId, - files: { - for (final entry in state.webSendState!.files.entries) entry.key: entry.value.file, - }, - ).toJson(), - ); - } - } - - final pinCorrect = await checkPin( - server: server, - pin: state.webSendState!.pin, - pinAttempts: state.webSendState!.pinAttempts, - request: request, - ); - if (!pinCorrect) { - return; - } - - final streamController = StreamController(); - final sessionId = request.ip; - server.setState( - (oldState) => oldState!.copyWith( - webSendState: oldState.webSendState!.copyWith( - sessions: { - ...oldState.webSendState!.sessions, - sessionId: WebSendSession( - sessionId: sessionId, - responseHandler: streamController, - ip: request.ip, - deviceInfo: request.deviceInfo, - ), - }, - ), - ), - ); - - final accepted = state.webSendState?.autoAccept == true || await streamController.stream.first; - if (!accepted) { - // user rejected the file transfer - server.setState( - (oldState) => oldState!.copyWith( - webSendState: oldState.webSendState!.copyWith( - sessions: { - for (final entry in oldState.webSendState!.sessions.entries) - if (entry.key != sessionId) entry.key: entry.value, // remove session - }, - ), - ), - ); - return await request.respondJson(403, message: 'File transfer rejected.'); - } - - server.setState( - (oldState) => oldState!.copyWith( - webSendState: oldState.webSendState!.updateSession( - sessionId: sessionId, - update: (oldSession) { - return oldSession.copyWith( - responseHandler: null, // this indicates that the session is active - ); - }, - ), - ), - ); - final deviceInfo = server.ref.read(deviceInfoProvider); - return await request.respondJson( - 200, - body: ReceiveRequestResponseDto( - info: InfoDto( - alias: alias, - version: protocolVersion, - deviceModel: deviceInfo.deviceModel, - deviceType: deviceInfo.deviceType, - fingerprint: fingerprint, - download: true, - ), - sessionId: sessionId, - files: { - for (final entry in state.webSendState!.files.entries) entry.key: entry.value.file, - }, - ).toJson(), - ); - }); - - router.get(ApiRoute.download.v2, (HttpRequest request) async { - final sessionId = request.uri.queryParameters['sessionId']; - if (sessionId == null) { - return await request.respondJson(400, message: 'Missing sessionId.'); - } - - final session = server.getState().webSendState?.sessions[sessionId]; - if (session == null || session.responseHandler != null || session.ip != request.ip) { - return await request.respondJson(403, message: 'Invalid sessionId.'); - } - - final fileId = request.uri.queryParameters['fileId']; - if (fileId == null) { - return await request.respondJson(400, message: 'Missing fileId.'); - } - - final file = server.getState().webSendState?.files[fileId]; - if (file == null) { - return await request.respondJson(403, message: 'Invalid fileId.'); - } - - final fileName = file.file.fileName.replaceAll('/', '-'); // File name may be inside directories - - request.response - ..statusCode = 200 - ..headers.set('content-type', 'application/octet-stream') - ..headers.set('content-disposition', 'attachment; filename="${Uri.encodeComponent(fileName)}"'); - - final isInlineContent = file.bytes != null; // text message, clipboard content - if (isInlineContent) { - request.response.headers.set('content-length', '${file.bytes!.length}'); - - final byteStream = Stream.fromIterable([file.bytes!]); - final (streamController, subscription) = byteStream.digested(); - - await request.response.addStream(streamController.stream).then((_) { - // ignore: discarded_futures - request.response.close(); - // ignore: discarded_futures - subscription.cancel(); - }); - } else { - final path = file.path!; - final isContentUri = path.startsWith('content://'); - - // Read file size at download time, since the file could have changed since it was selected (#2359, #2043) - final fileSize = isContentUri ? await UriContent().getContentLength(Uri.parse(path)) : File(path).lengthSync(); - request.response.headers.set('content-length', '$fileSize'); - - final fileStream = isContentUri ? UriContent().getContentStream(Uri.parse(path)) : File(path).openRead(); - final (streamController, subscription) = fileStream.digested(); - - await request.response.addStream(streamController.stream).then((_) { - request.response.close(); // ignore: discarded_futures - subscription.cancel(); // ignore: discarded_futures - }); - } - }); - } - - Future initializeWebSend({required List files}) async { - final webSendState = WebSendState( + return WebSendState( sessions: {}, files: Map.fromEntries( await Future.wait( files.map((file) async { final id = _uuid.v4(); + + String? path = file.path; + if (path == null && file.bytes != null) { + // The Rust server streams file content from disk, so in-memory + // bytes (text messages, clipboard content) are written to a temp file. + final tempPath = p.join(await getCacheDirectory(), 'web-send-$id'); + await File(tempPath).writeAsBytes(file.bytes!); + path = tempPath; + } + return MapEntry( id, WebSendFile( @@ -275,41 +71,147 @@ class SendController { : null, ), asset: file.asset, - path: file.path, + path: path, bytes: file.bytes, ), ); }), ), ), - autoAccept: server.ref.read(settingsProvider).shareViaLinkAutoAccept, - pin: null, - pinAttempts: {}, - ); - - server.setState( - (oldState) => oldState?.copyWith( - webSendState: webSendState, - ), + autoAccept: currentWebSendState?.autoAccept ?? server.ref.read(settingsProvider).shareViaLinkAutoAccept, + pin: currentWebSendState?.pin, ); } - void acceptRequest(String sessionId) { - _respondRequest(sessionId, true); - } - - void declineRequest(String sessionId) { - _respondRequest(sessionId, false); - } - - void _respondRequest(String sessionId, bool accepted) { - final controller = server.getState().webSendState?.sessions[sessionId]?.responseHandler; - if (controller == null) { + /// A web client requests to download the shared files. + /// The Rust server already checked the PIN and handles repeated visits of + /// accepted sessions itself. + void onPrepareDownload(HttpServerWebPrepareDownloadEvent event) { + final webSendState = server.getStateOrNull()?.webSendState; + if (webSendState == null) { + // should not happen: web send events are only emitted when web send was configured + server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerPrepareDownloadDecisionAction(sessionId: event.sessionId, accept: false)); return; } - controller.add(accepted); - controller.close(); // ignore: discarded_futures + server.setState( + (oldState) => oldState!.copyWith( + webSendState: oldState.webSendState!.copyWith( + sessions: { + ...oldState.webSendState!.sessions, + event.sessionId: WebSendSession( + sessionId: event.sessionId, + pending: true, + ip: event.ip, + deviceInfo: parseDeviceInfoFromUserAgent(event.userAgent), + ), + }, + ), + ), + ); + + if (webSendState.autoAccept) { + acceptRequest(event.sessionId); + } + } + + /// A web client downloads an offered file. + /// The Rust server already validated the session; it streams the content + /// from the source resolved here. + Future onFileDownload(HttpServerWebFileDownloadEvent event) async { + final String? filePath; + final int? fileDescriptor; + try { + final path = server.getStateOrNull()?.webSendState?.files[event.fileId]?.path; + if (path == null) { + // should not happen: the Rust server only emits events for offered files + throw StateError('No path for web send file ${event.fileId}'); + } + + if (path.startsWith('content://')) { + filePath = null; + fileDescriptor = await isolate_android_channel.getFileDescriptorAndroid(uri: path); + } else { + filePath = path; + fileDescriptor = null; + } + } catch (e, st) { + _logger.severe('Failed to resolve source for web send file ${event.fileId}', e, st); + // Unblock the web client's request waiting for the content source. + server.ref + .redux(parentIsolateProvider) + .dispatch(IsolateHttpServerRejectFileDownloadAction(sessionId: event.sessionId, fileId: event.fileId)); + return; + } + + server.ref + .redux(parentIsolateProvider) + .dispatch( + IsolateHttpServerFileDownloadTargetAction( + sessionId: event.sessionId, + fileId: event.fileId, + path: filePath, + fileDescriptor: fileDescriptor, + ), + ); + } + + void acceptRequest(String sessionId) { + final session = server.getStateOrNull()?.webSendState?.sessions[sessionId]; + if (session == null || !session.pending) { + return; + } + + server.setState( + (oldState) => oldState!.copyWith( + webSendState: oldState.webSendState!.updateSession( + sessionId: sessionId, + update: (oldSession) => oldSession.copyWith(pending: false), + ), + ), + ); + + server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerPrepareDownloadDecisionAction(sessionId: sessionId, accept: true)); + } + + void declineRequest(String sessionId) { + final session = server.getStateOrNull()?.webSendState?.sessions[sessionId]; + if (session == null || !session.pending) { + return; + } + + server.setState( + (oldState) => oldState!.copyWith( + webSendState: oldState.webSendState!.copyWith( + sessions: { + for (final entry in oldState.webSendState!.sessions.entries) + if (entry.key != sessionId) entry.key: entry.value, // remove session + }, + ), + ), + ); + + server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerPrepareDownloadDecisionAction(sessionId: sessionId, accept: false)); + } +} + +/// Parses a human-readable device description from a user agent. +String parseDeviceInfoFromUserAgent(String? userAgent) { + if (userAgent == null) { + return 'Unknown'; + } + + final userAgentAnalyzer = UserAgentAnalyzer(); + final browser = userAgentAnalyzer.getBrowser(userAgent); + final os = userAgentAnalyzer.getOS(userAgent); + if (browser != null && os != null) { + return '$browser ($os)'; + } else if (browser != null) { + return browser; + } else if (os != null) { + return os; + } else { + return 'Unknown'; } } diff --git a/app/lib/provider/network/server/server_provider.dart b/app/lib/provider/network/server/server_provider.dart index 02db1f0a..dbdf46bd 100644 --- a/app/lib/provider/network/server/server_provider.dart +++ b/app/lib/provider/network/server/server_provider.dart @@ -1,18 +1,19 @@ import 'dart:async'; -import 'dart:io'; +import 'package:localsend_app/gen/strings.g.dart'; import 'package:localsend_app/model/cross_file.dart'; +import 'package:localsend_app/model/state/send/web/web_send_state.dart'; import 'package:localsend_app/model/state/server/server_state.dart'; import 'package:localsend_app/provider/network/server/controller/receive_controller.dart'; import 'package:localsend_app/provider/network/server/controller/send_controller.dart'; import 'package:localsend_app/provider/network/server/server_utils.dart'; -import 'package:localsend_app/provider/security_provider.dart'; import 'package:localsend_app/provider/settings_provider.dart'; import 'package:localsend_app/util/alias_generator.dart'; -import 'package:localsend_app/util/simple_server.dart'; import 'package:localsend_isolates/constants.dart'; import 'package:localsend_isolates/isolate.dart'; import 'package:localsend_isolates/model/dto/multicast_dto.dart'; +import 'package:localsend_isolates/rust/api/server.dart' show WebSendI18n, WebSendParams; +import 'package:localsend_isolates/util/rust.dart'; import 'package:logging/logging.dart'; import 'package:refena_flutter/refena_flutter.dart'; @@ -22,6 +23,9 @@ final _logger = Logger('Server'); /// It is a singleton provider, so only one server can be running at a time. /// The server state is null if the server is not running. /// The server can receive files (since v1) and send files (since v2). +/// +/// The HTTP server itself runs in Rust (inside the server isolate); this +/// provider starts it, listens to its events and holds the resulting state. final serverProvider = NotifierProvider( (ref) { return ServerService(); @@ -67,6 +71,8 @@ class ServerService extends Notifier { late final _receiveController = ReceiveController(_serverUtils); late final _sendController = SendController(_serverUtils); + StreamSubscription? _subscription; + ServerService(); @override @@ -85,10 +91,12 @@ class ServerService extends Notifier { } /// Starts the server. + /// Passing a [webSendState] additionally serves the web send (download) API. Future startServer({ required String alias, required int port, required bool https, + WebSendState? webSendState, }) async { if (state != null) { _logger.info('Server already running.'); @@ -104,62 +112,89 @@ class ServerService extends Notifier { port = defaultPort; } - final router = SimpleServerRouteBuilder(); - final fingerprint = ref.read(securityProvider).certificateHash; - _receiveController.installRoutes( - router: router, - alias: alias, - port: port, - https: https, - fingerprint: fingerprint, - showToken: ref.read(settingsProvider).showToken, - ); - _sendController.installRoutes( - router: router, - alias: alias, - fingerprint: fingerprint, - ); - _logger.info('Starting server...'); - final HttpServer httpServer; - if (https) { - final securityContext = ref.read(securityProvider); - httpServer = await HttpServer.bindSecure( - '0.0.0.0', - port, - SecurityContext() - ..usePrivateKeyBytes(securityContext.privateKey.codeUnits) - ..useCertificateChainBytes(securityContext.certificate.codeUnits), - ); - _logger.info('Server started. (Port: $port, HTTPS only)'); - } else { - httpServer = await HttpServer.bind( - '0.0.0.0', - port, - ); - _logger.info('Server started. (Port: $port, HTTP only)'); + // The server isolate derives its configuration from the sync state, + // so it must be published before the start task. + _syncServerState(alias: alias, port: port, https: https, serverRunning: true, download: webSendState != null); + + final settings = ref.read(settingsProvider); + final events = ref + .redux(parentIsolateProvider) + .dispatchTakeResult( + IsolateHttpServerStartAction( + pin: settings.receivePin, + webSend: webSendState != null + ? WebSendParams( + files: { + for (final entry in webSendState.files.entries) entry.key: entry.value.file.toRust(), + }, + pin: webSendState.pin, + i18N: WebSendI18n( + waiting: t.web.waiting, + enterPin: t.web.enterPin, + invalidPin: t.web.invalidPin, + tooManyAttempts: t.web.tooManyAttempts, + rejected: t.web.rejected, + files: t.web.files, + fileName: t.web.fileName, + size: t.web.size, + ), + ) + : null, + showToken: settings.showToken, + ), + ); + + final started = Completer(); + final subscription = events.listen( + (event) { + if (event is HttpServerStartedEvent) { + if (!started.isCompleted) { + started.complete(); + } + return; + } + _handleEvent(event); + }, + onError: (Object error) { + if (!started.isCompleted) { + started.completeError(error); + } else { + _logger.severe('HTTP server error: $error'); + } + }, + ); + + try { + await started.future; + } catch (e) { + await subscription.cancel(); + _syncServerState(alias: alias, port: port, https: https, serverRunning: false, download: false); + _logger.warning('Failed to start server', e); + rethrow; } - final server = SimpleServer.start(server: httpServer, routes: router); + _subscription = subscription; final newServerState = ServerState( - httpServer: server, alias: alias, port: port, https: https, session: null, - webSendState: null, - pinAttempts: {}, + webSendState: webSendState, ); state = newServerState; + _logger.info('Server started. (Port: $port, ${https ? 'HTTPS' : 'HTTP'} only)'); return newServerState; } Future stopServer() async { _logger.info('Stopping server...'); - await state?.httpServer.close(); + await _subscription?.cancel(); + _subscription = null; + await ref.redux(parentIsolateProvider).dispatchAsync(IsolateHttpServerStopAction()); state = null; _logger.info('Server stopped.'); } @@ -169,13 +204,13 @@ class ServerService extends Notifier { return await startServerFromSettings(); } - Future restartServer({required String alias, required int port, required bool https}) async { + Future restartServer({required String alias, required int port, required bool https, WebSendState? webSendState}) async { await stopServer(); - return await startServer(alias: alias, port: port, https: https); + return await startServer(alias: alias, port: port, https: https, webSendState: webSendState); } - void acceptFileRequest(Map fileNameMap) { - _receiveController.acceptFileRequest(fileNameMap); + Future acceptFileRequest(Map fileNameMap) async { + await _receiveController.acceptFileRequest(fileNameMap); } void declineFileRequest() { @@ -202,17 +237,32 @@ class ServerService extends Notifier { _receiveController.closeSession(); } - /// Initializes the web send state. - Future initializeWebSend(List files) async { - await _sendController.initializeWebSend(files: files); + /// Restarts the server with web send (the download API) enabled for [files]. + /// The auto accept setting and the pin of a previous web send state are kept. + Future restartServerWithWebSend({ + required String alias, + required int port, + required bool https, + required List files, + }) async { + final webSendState = await _sendController.buildWebSendState(files: files); + await restartServer(alias: alias, port: port, https: https, webSendState: webSendState); } /// Updates the web send pin. - void setWebSendPin(String? pin) { - state = state?.copyWith( - webSendState: state?.webSendState?.copyWith( - pin: pin, - ), + /// The pin is enforced by the Rust server, so the server is restarted. + Future setWebSendPin(String? pin) async { + final current = state; + final webSendState = current?.webSendState; + if (current == null || webSendState == null || webSendState.pin == pin) { + return; + } + + await restartServer( + alias: current.alias, + port: current.port, + https: current.https, + webSendState: webSendState.copyWith(sessions: {}, pin: pin), ); } @@ -234,32 +284,57 @@ class ServerService extends Notifier { void declineWebSendRequest(String sessionId) { _sendController.declineRequest(sessionId); } + + void _handleEvent(HttpServerEvent event) { + switch (event) { + case HttpServerStartedEvent(): + break; + case HttpServerRegisterEvent(): + // ignore: discarded_futures + _receiveController.onRegister(event); + case HttpServerPrepareUploadEvent(): + // ignore: discarded_futures + _receiveController.onPrepareUpload(event); + case HttpServerFileUploadEvent(): + // ignore: discarded_futures + _receiveController.onFileUpload(event); + case HttpServerFileUploadProgressEvent(): + case HttpServerFileUploadResultEvent(): + // emitted on the stream of the file upload target task, not here + break; + case HttpServerSessionEndEvent(): + _receiveController.onSessionEnd(event); + case HttpServerPrepareUploadAbortedEvent(): + _receiveController.onPrepareUploadAborted(event); + case HttpServerCancelReceivedEvent(): + _receiveController.onCancelReceived(event); + case HttpServerShowEvent(): + _receiveController.onShow(event); + case HttpServerWebPrepareDownloadEvent(): + _sendController.onPrepareDownload(event); + case HttpServerWebFileDownloadEvent(): + // ignore: discarded_futures + _sendController.onFileDownload(event); + } + } + + void _syncServerState({ + required String alias, + required int port, + required bool https, + required bool serverRunning, + required bool download, + }) { + ref + .redux(parentIsolateProvider) + .dispatch( + IsolateSyncServerStateAction( + alias: alias, + port: port, + protocol: https ? ProtocolType.https : ProtocolType.http, + serverRunning: serverRunning, + download: download, + ), + ); + } } - -// Below is a first prototype of mTLS (mutual TLS). -// Problem: -// - we cannot request client certificates while ignoring errors - -// Future _startServer({ -// required Router router, -// required int port, -// required SecurityContext? securityContext, -// }) async { -// const address = '0.0.0.0'; -// final server = await (securityContext == null -// ? HttpServer.bind(address, port) -// : HttpServer.bindSecure( -// address, -// port, -// securityContext, -// requestClientCertificate: true, -// )); -// -// final stream = server.map((request) { -// print('Request Cert: ${request.certificate?.pem}'); -// return request; -// }); -// -// serveRequests(stream, router); -// return server; -// } diff --git a/app/lib/util/native/channel/android_channel.dart b/app/lib/util/native/channel/android_channel.dart index 081fcd47..28cc9bff 100644 --- a/app/lib/util/native/channel/android_channel.dart +++ b/app/lib/util/native/channel/android_channel.dart @@ -78,6 +78,38 @@ Future createMissingDirectoriesAndroid({ } } +class CreatedFileAndroid { + /// The URI of the created document. Android may rename the file on collisions. + final String uri; + + /// An owned writable Linux file descriptor. It stays open after this call and + /// must be closed by the native consumer it is passed to. + final int fileDescriptor; + + CreatedFileAndroid({required this.uri, required this.fileDescriptor}); +} + +/// Creates a new file inside a SAF directory (a tree or document URI) +/// and opens it for writing. +Future createFileAndroid({ + required String parentUri, + required String fileName, + required String mimeType, +}) async { + final result = await _methodChannel.invokeMethod('createFile', { + 'parentUri': parentUri, + 'fileName': fileName, + 'mimeType': mimeType, + }); + if (result == null) { + throw StateError('Android could not create $fileName in $parentUri'); + } + return CreatedFileAndroid( + uri: result['uri'] as String, + fileDescriptor: result['fd'] as int, + ); +} + Future openContentUri({ required String uri, }) async { diff --git a/app/lib/util/native/file_saver.dart b/app/lib/util/native/file_saver.dart index b8d53527..f005c1cd 100644 --- a/app/lib/util/native/file_saver.dart +++ b/app/lib/util/native/file_saver.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'dart:typed_data'; import 'package:gal/gal.dart'; import 'package:legalize/legalize.dart'; @@ -10,38 +9,45 @@ import 'package:localsend_app/util/native/directories.dart'; import 'package:logging/logging.dart'; import 'package:mime/mime.dart'; import 'package:path/path.dart' as p; -import 'package:saf_stream/saf_stream.dart'; -import 'package:saf_stream/saf_stream_platform_interface.dart'; final _logger = Logger('FileSaver'); -final _saf = SafStream(); +/// Where an incoming file should be written to. +/// The actual writing is done by the Rust server which receives either a +/// plain file [path] or a writable [fileDescriptor] (Android SAF). +class FileSaveTarget { + /// The path to write the file to. `null` when [fileDescriptor] is used. + final String? path; -/// Saves file from [stream] to [destinationDirectory] with [fileName]. + /// A writable file descriptor (Android SAF). `null` when [path] is used. + final int? fileDescriptor; + + /// The path or content URI of the destination, used for the history + /// and to locate the file after it has been written. + final String displayPath; + + FileSaveTarget({ + required this.path, + required this.fileDescriptor, + required this.displayPath, + }); +} + +/// Prepares the destination for an incoming file with [fileName]. /// -/// When [saveToGallery] is true: -/// - Saves to cache directory first, then transfers to OS gallery (Photos/Videos) -/// - If format is unsupported (see https://github.com/natsuk4ze/gal/wiki/Formats), -/// moves the cached file to [destinationDirectory] as fallback +/// When [saveToGallery] is true, the file is first written to the cache +/// directory; call [saveCachedFileToGallery] after the file has been written. /// -/// [onProgress] is called every 100ms with the number of bytes saved. -/// -/// Returns (savedToGallery, filePath): -/// - savedToGallery: true if saved to gallery, false if saved to directory -/// - filePath: absolute path to file (null when saved to gallery) -/// -/// Throws [GalException] for permission or I/O errors (file will be deleted on error) -Future<(bool, String?)> saveFile({ +/// On Android, destinations that cannot be written directly (SAF content URIs +/// and SD cards) are created via the Storage Access Framework and a writable +/// file descriptor is returned instead of a path. +Future prepareFileSaveTarget({ required String destinationDirectory, required String fileName, required bool saveToGallery, required bool isImage, - required Stream stream, - required void Function(int) onProgress, required Set createdDirectories, int? androidSdkInt, - DateTime? lastModified, - DateTime? lastAccessed, }) async { final parentDirectory = saveToGallery ? await getCacheDirectory() : destinationDirectory; @@ -51,160 +57,104 @@ Future<(bool, String?)> saveFile({ createdDirectories: createdDirectories, ); - // When saveToGallery is enabled, cache directory is used so SAF is not needed + // When saveToGallery is enabled, the cache directory is used so SAF is not needed if (!saveToGallery && androidSdkInt != null) { - SafWriteStreamInfo? safInfo; - + String? parentUri; if (documentUri != null || destinationPath.startsWith('content://')) { - _logger.info('Using SAF to save file to ${documentUri ?? destinationPath} as $finalName'); - safInfo = await _saf.startWriteStream( - documentUri ?? destinationPath, - finalName, - lookupMimeType(finalName) ?? (isImage ? 'image/*' : '*/*'), - ); + parentUri = documentUri ?? destinationPath; } else { final sdCardPath = getSdCardPath(destinationPath); if (sdCardPath != null) { final uriString = ContentUriHelper.encodeTreeUri(sdCardPath.path.parentPath()); - _logger.info('Using SAF to save file to $uriString'); - safInfo = await _saf.startWriteStream( - 'content://com.android.externalstorage.documents/tree/${sdCardPath.sdCardId}:$uriString', - finalName, - lookupMimeType(finalName) ?? (isImage ? 'image/*' : '*/*'), - ); + parentUri = 'content://com.android.externalstorage.documents/tree/${sdCardPath.sdCardId}:$uriString'; } } - if (safInfo != null) { - final sessionID = safInfo.session; - return await _saveFile( - destinationPath: destinationPath, - saveToGallery: saveToGallery, - destinationDirectory: destinationDirectory, - fileName: fileName, - createdDirectories: createdDirectories, - isImage: isImage, - stream: stream, - onProgress: onProgress, - write: null, - writeAsync: (data) async { - await _saf.writeChunk(sessionID, data); - }, - flush: null, - close: () async { - await _saf.endWriteStream(sessionID); - }, + if (parentUri != null) { + _logger.info('Using SAF to save file to $parentUri as $finalName'); + final createdFile = await android_channel.createFileAndroid( + parentUri: parentUri, + fileName: finalName, + mimeType: lookupMimeType(finalName) ?? (isImage ? 'image/*' : '*/*'), + ); + return FileSaveTarget( + path: null, + fileDescriptor: createdFile.fileDescriptor, + displayPath: createdFile.uri, ); } } - final file = File(destinationPath); - final sink = file.openWrite(); - return await _saveFile( - destinationPath: destinationPath, - saveToGallery: saveToGallery, - destinationDirectory: destinationDirectory, - fileName: fileName, - createdDirectories: createdDirectories, - isImage: isImage, - stream: stream, - onProgress: onProgress, - write: sink.add, - writeAsync: null, - flush: sink.flush, - close: () async { - await sink.close(); - if (lastModified != null) { - try { - await file.setLastModified(lastModified); - } catch (_) {} - } - if (lastAccessed != null) { - try { - await file.setLastAccessed(lastAccessed); - } catch (_) {} - } - }, + return FileSaveTarget( + path: destinationPath, + fileDescriptor: null, + displayPath: destinationPath, ); } -Future<(bool, String?)> _saveFile({ - required String destinationPath, - required bool saveToGallery, +/// Applies the file timestamps after the file has been written to a plain path. +Future applyFileTimestamps({ + required FileSaveTarget target, + DateTime? lastModified, + DateTime? lastAccessed, +}) async { + final path = target.path; + if (path == null) { + return; + } + final file = File(path); + if (lastModified != null) { + try { + await file.setLastModified(lastModified); + } catch (_) {} + } + if (lastAccessed != null) { + try { + await file.setLastAccessed(lastAccessed); + } catch (_) {} + } +} + +/// Moves a file that has been written to the cache directory into the +/// OS gallery (Photos/Videos). +/// +/// If the gallery rejects the file (unsupported format, missing permission, +/// not enough space, ...), the cached file is moved to [destinationDirectory] +/// as fallback. At this point the file is already fully received and the +/// sender was already told success, so failing the transfer would lose it. +/// +/// Returns (savedToGallery, filePath): +/// - savedToGallery: true if saved to gallery, false if saved to directory +/// - filePath: absolute path to file (null when saved to gallery) +Future<(bool, String?)> saveCachedFileToGallery({ + required String cachedPath, required String destinationDirectory, required String fileName, - required Set createdDirectories, required bool isImage, - required Stream stream, - required void Function(int) onProgress, - required void Function(Uint8List data)? write, - required Future Function(Uint8List data)? writeAsync, - required Future Function()? flush, - required Future Function() close, + required Set createdDirectories, }) async { try { - int savedBytes = 0; - int lastFlushedBytes = 0; - final stopwatch = Stopwatch()..start(); - await for (final event in stream) { - if (writeAsync != null) { - await writeAsync(event); - } else { - write!(event); - } + isImage ? await Gal.putImage(cachedPath) : await Gal.putVideo(cachedPath); + } on GalException catch (e) { + _logger.warning('Could not save to gallery (${e.type.name}), moving to destination directory', e); - savedBytes += event.length; - if (stopwatch.elapsedMilliseconds >= 100) { - stopwatch.reset(); - onProgress(savedBytes); - } + final (fallbackPath, _, _) = await digestFilePathAndPrepareDirectory( + parentDirectory: destinationDirectory, + fileName: fileName, + createdDirectories: createdDirectories, + ); - const tenMB = 10 * 1024 * 1024; - if (flush != null && savedBytes >= lastFlushedBytes + tenMB) { - await flush(); - lastFlushedBytes = savedBytes; - } - } - - await flush?.call(); - await close(); - - if (saveToGallery) { - try { - isImage ? await Gal.putImage(destinationPath) : await Gal.putVideo(destinationPath); - await File(destinationPath).delete(); - onProgress(savedBytes); - return (true, null); - } on GalException catch (e) { - if (e.type == GalExceptionType.notSupportedFormat) { - _logger.info('File format not supported by gallery, moving to destination directory'); - - final (fallbackPath, _, _) = await digestFilePathAndPrepareDirectory( - parentDirectory: destinationDirectory, - fileName: fileName, - createdDirectories: createdDirectories, - ); - - _logger.info('Moving file from $destinationPath to $fallbackPath'); - await File(destinationPath).rename(fallbackPath); - onProgress(savedBytes); - return (false, fallbackPath); - } - rethrow; - } - } - - onProgress(savedBytes); // always emit final event - return (false, destinationPath); // Saved to destination (not gallery) - } catch (_) { - try { - await close(); - await File(destinationPath).delete(); - } catch (e) { - _logger.warning('Could not delete file', e); - } - rethrow; + _logger.info('Moving file from $cachedPath to $fallbackPath'); + await File(cachedPath).rename(fallbackPath); + return (false, fallbackPath); } + + try { + await File(cachedPath).delete(); + } catch (e) { + _logger.warning('Could not delete cached file after saving to gallery', e); + } + return (true, null); } /// If there is a file with the same name, then it appends a number to its file name diff --git a/app/lib/util/simple_server.dart b/app/lib/util/simple_server.dart deleted file mode 100644 index 68984363..00000000 --- a/app/lib/util/simple_server.dart +++ /dev/null @@ -1,143 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/services.dart'; -import 'package:localsend_app/util/user_agent_analyzer.dart'; - -/// A very light layer on top of the HttpServer class. -class SimpleServer { - final HttpServer _server; - - SimpleServer.start({ - required HttpServer server, - required SimpleServerRouteBuilder routes, - }) : _server = server { - _server.listen((request) async { - final handler = - routes._routes[Route( - HttpMethod.values.firstWhere((e) => e.methodName == request.method), - request.uri.path, - )]; - - if (handler != null) { - handler.call(request); - } else { - request.response.statusCode = HttpStatus.notFound; - request.response.write('Not found'); - await request.response.flush(); - await request.response.close(); - } - }); - } - - Future close() async { - await _server.close(force: true); - } -} - -typedef HttpRequestHandler = void Function(HttpRequest request); - -enum HttpMethod { - get('GET'), - post('POST') - ; - - const HttpMethod(this.methodName); - - final String methodName; -} - -class Route { - final HttpMethod method; - final String path; - - Route(this.method, this.path); - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is Route && other.method == method && other.path == path; - } - - @override - int get hashCode => method.hashCode ^ path.hashCode; -} - -class SimpleServerRouteBuilder { - final Map _routes = {}; - - void addRoute(HttpMethod method, String path, HttpRequestHandler handler) { - _routes[Route( - method, - path, - )] = - handler; - } - - void get(String path, HttpRequestHandler handler) { - addRoute(HttpMethod.get, path, handler); - } - - void post(String path, HttpRequestHandler handler) { - addRoute(HttpMethod.post, path, handler); - } -} - -extension RequestExt on HttpRequest { - Future readAsString() async { - return utf8.decodeStream(this); - } - - /// Syntax sugar for creating an API response. - /// - /// Usage: - /// respondJson(200, message: 'Hello World') - Future respondJson(int code, {String? message, Map? body}) async { - response - ..statusCode = code - ..headers.contentType = ContentType.json - ..write(jsonEncode(message != null ? {'message': message} : (body ?? {}))); - - await response.close(); - } - - /// Syntax sugar for creating an HTML response. - /// - /// Usage: - /// respondAsset(200, 'assets/web/index.html') - Future respondAsset(int code, String asset, [String type = 'text/html; charset=utf-8']) async { - response - ..statusCode = code - ..headers.contentType = ContentType.parse(type) - ..write(await rootBundle.loadString(asset)); - - await response.close(); - } - - /// The IP address of the client. - String get ip { - return connectionInfo!.remoteAddress.address; - } - - /// Client's device info parsed from the user agent. - String get deviceInfo { - final userAgent = headers['user-agent']?.first; - if (userAgent == null) { - return 'Unknown'; - } - - final userAgentAnalyzer = UserAgentAnalyzer(); - final browser = userAgentAnalyzer.getBrowser(userAgent); - final os = userAgentAnalyzer.getOS(userAgent); - if (browser != null && os != null) { - return '$browser ($os)'; - } else if (browser != null) { - return browser; - } else if (os != null) { - return os; - } else { - return 'Unknown'; - } - } -} diff --git a/app/lib/widget/dialogs/qr_dialog.dart b/app/lib/widget/dialogs/qr_dialog.dart index 4db3dab0..40f2ac3a 100644 --- a/app/lib/widget/dialogs/qr_dialog.dart +++ b/app/lib/widget/dialogs/qr_dialog.dart @@ -56,7 +56,7 @@ class QrDialog extends StatelessWidget { if (listenIncomingWebSendRequests && webSendState != null) Builder( builder: (context) { - final pending = webSendState?.sessions.values.fold(0, (prev, curr) => prev + (curr.responseHandler != null ? 1 : 0)) ?? 0; + final pending = webSendState?.sessions.values.fold(0, (prev, curr) => prev + (curr.pending ? 1 : 0)) ?? 0; if (pending != 0) { return Padding( padding: const EdgeInsets.only(top: 5), diff --git a/app/lib/widget/dialogs/zoom_dialog.dart b/app/lib/widget/dialogs/zoom_dialog.dart index 22496bfe..60afde49 100644 --- a/app/lib/widget/dialogs/zoom_dialog.dart +++ b/app/lib/widget/dialogs/zoom_dialog.dart @@ -59,7 +59,7 @@ class ZoomDialog extends StatelessWidget { if (listenIncomingWebSendRequests && webSendState != null) Builder( builder: (context) { - final pending = webSendState?.sessions.values.fold(0, (prev, curr) => prev + (curr.responseHandler != null ? 1 : 0)) ?? 0; + final pending = webSendState?.sessions.values.fold(0, (prev, curr) => prev + (curr.pending ? 1 : 0)) ?? 0; if (pending != 0) { return Padding( padding: const EdgeInsets.only(top: 5), diff --git a/packages/core/Cargo.lock b/packages/core/Cargo.lock index 552f5d40..f7818c79 100644 --- a/packages/core/Cargo.lock +++ b/packages/core/Cargo.lock @@ -2493,6 +2493,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] diff --git a/packages/core/Cargo.toml b/packages/core/Cargo.toml index 05e0c001..c1876bbb 100644 --- a/packages/core/Cargo.toml +++ b/packages/core/Cargo.toml @@ -30,7 +30,7 @@ tokio = { version = "1.49.0", features = ["full"] } tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring", "tls12"], optional = true } tokio-stream = "0.1.18" tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"], optional = true } -tokio-util = { version = "0.7.16", optional = true } +tokio-util = { version = "0.7.16", features = ["rt"], optional = true } tracing = "0.1.44" tracing-subscriber = { version = "0.3.20" } tungstenite = "0.28.0" diff --git a/packages/core/src/http/server/common/save.rs b/packages/core/src/http/server/common/save.rs index 27f3bd72..66020e12 100644 --- a/packages/core/src/http/server/common/save.rs +++ b/packages/core/src/http/server/common/save.rs @@ -35,6 +35,10 @@ pub enum FileUploadTarget { /// Channel on which the server reports whether the file was saved successfully. result_tx: oneshot::Sender>, + + /// Optional channel on which the server reports the number of bytes + /// written so far. Events are dropped when the channel is full. + progress_tx: Option>, }, /// The server writes the file to this raw file descriptor (Android only) @@ -47,6 +51,10 @@ pub enum FileUploadTarget { /// Channel on which the server reports whether the file was saved successfully. result_tx: oneshot::Sender>, + + /// Optional channel on which the server reports the number of bytes + /// written so far. Events are dropped when the channel is full. + progress_tx: Option>, }, } @@ -61,7 +69,11 @@ pub(crate) async fn save_req_to_target( binary_tx, result_rx, } => (binary_tx, result_rx), - FileUploadTarget::Path { path, result_tx } => spawn_file_writer( + FileUploadTarget::Path { + path, + result_tx, + progress_tx, + } => spawn_file_writer( async move { tokio::fs::File::create(&path) .await @@ -69,9 +81,14 @@ pub(crate) async fn save_req_to_target( }, file_size, result_tx, + progress_tx, ), #[cfg(target_os = "android")] - FileUploadTarget::Fd { fd, result_tx } => spawn_file_writer( + FileUploadTarget::Fd { + fd, + result_tx, + progress_tx, + } => spawn_file_writer( async move { use std::os::fd::FromRawFd; @@ -82,6 +99,7 @@ pub(crate) async fn save_req_to_target( }, file_size, result_tx, + progress_tx, ), }; @@ -136,12 +154,14 @@ fn spawn_file_writer( open: impl Future> + Send + 'static, expected_size: u64, result_tx: oneshot::Sender>, + progress_tx: Option>, ) -> (mpsc::Sender, oneshot::Receiver>) { let (binary_tx, mut binary_rx) = mpsc::channel::(UPLOAD_CHANNEL_CAPACITY); let (internal_tx, internal_rx) = oneshot::channel::>(); tokio::spawn(async move { - let result = write_file_from_receiver(open, expected_size, &mut binary_rx).await; + let result = + write_file_from_receiver(open, expected_size, &mut binary_rx, progress_tx).await; // Unblock the request handler if it is still sending chunks. binary_rx.close(); let _ = result_tx.send(result.clone()); @@ -159,6 +179,7 @@ async fn write_file_from_receiver( open: impl Future>, expected_size: u64, rx: &mut mpsc::Receiver, + progress_tx: Option>, ) -> Result<(), String> { use tokio::io::AsyncWriteExt; @@ -174,6 +195,10 @@ async fn write_file_from_receiver( file.write_all(&chunk) .await .map_err(|e| format!("Failed to write file: {e}"))?; + if let Some(progress_tx) = &progress_tx { + // Progress is best-effort: drop the event when the consumer lags. + let _ = progress_tx.try_send(written); + } } file.flush() .await diff --git a/packages/core/src/http/server/mod.rs b/packages/core/src/http/server/mod.rs index 856d49b8..a38d2c2d 100644 --- a/packages/core/src/http/server/mod.rs +++ b/packages/core/src/http/server/mod.rs @@ -27,6 +27,8 @@ use std::num::NonZeroUsize; use std::ops::Deref; use std::sync::Arc; use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio_util::sync::CancellationToken; +use tokio_util::task::TaskTracker; use web::WebPageState; /// Configuration for the v2 (legacy) protocol endpoints. @@ -108,6 +110,51 @@ impl AppState { } } +/// A handle to a running server for interactions initiated by the application +/// (as opposed to the event channels which are driven by incoming requests). +pub struct ServerHandle { + v2: Option>, + + /// The task running the accept loops. Completes after a stop has been + /// requested, the listeners have been dropped and all connections have + /// been closed. + task: Mutex>>, +} + +impl ServerHandle { + /// Waits until the server task has terminated, the listeners are closed + /// and all connections have been dropped, so that the port can be bound again. + /// Must be called after requesting a stop via the stop channel. + pub async fn wait_stopped(&self) { + if let Some(task) = self.task.lock().await.take() { + let _ = task.await; + } + } + + /// Cancels the active v2 upload session if it matches `session_id`, + /// e.g. because the user aborted the transfer on the receiving side. + /// + /// Uploads that are already in progress still run to completion, but new + /// upload requests are rejected and a new session can be created. + /// No [ServerEventV2::SessionEnd] is emitted: the application initiated + /// the cancellation itself. + /// + /// Returns `true` when a session was cancelled. + pub async fn cancel_v2_session(&self, session_id: &str) -> bool { + let Some(v2) = &self.v2 else { + return false; + }; + let mut slot = v2.session.lock().await; + match slot.as_ref() { + Some(SessionStateV2::Active(session)) if session.session_id == session_id => { + *slot = None; + true + } + _ => false, + } + } +} + /// Binds the server to the specified port on both IPv4 and IPv6 addresses. pub async fn start_with_port( port: u16, @@ -117,7 +164,7 @@ pub async fn start_with_port( v2_config: Option, web_send_config: Option, stop_rx: oneshot::Receiver<()>, -) -> anyhow::Result<()> { +) -> anyhow::Result { let ipv4_socket_addr = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), port); let ipv6_socket_addr = SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), port); let info = Arc::new(Mutex::new(info)); @@ -132,16 +179,21 @@ pub async fn start_with_port( } }; - tokio::spawn({ + let cancel = CancellationToken::new(); + let connections = TaskTracker::new(); + + let task = tokio::spawn({ let state = state.clone(); + let cancel = cancel.clone(); + let connections = connections.clone(); async move { tokio::select! { - _ = start_server_with_listener(ipv4_listener, tls_config.clone(), state.clone()) => { + _ = start_server_with_listener(ipv4_listener, tls_config.clone(), state.clone(), cancel.clone(), connections.clone()) => { tracing::info!("Server stopped on: {}", ipv4_socket_addr); } _ = async { if let Some(listener) = ipv6_listener { - let _ = start_server_with_listener(listener, tls_config, state).await; + let _ = start_server_with_listener(listener, tls_config, state, cancel.clone(), connections.clone()).await; } // Keep the future running forever, so we continue using "ipv4 only" even if ipv6 fails. @@ -149,10 +201,19 @@ pub async fn start_with_port( } => {} _ = stop_rx => {} } + + // Hard-drop connections that are still being served, so that no + // client keeps talking to the stopped server. + cancel.cancel(); + connections.close(); + connections.wait().await; } }); - Ok(()) + Ok(ServerHandle { + v2: state.v2.clone(), + task: Mutex::new(Some(task)), + }) } /// Binds an IPv6 listener with `IPV6_V6ONLY` enabled. @@ -184,6 +245,8 @@ async fn start_server_with_listener( incoming: tokio::net::TcpListener, tls_config: Option, app_state: AppState, + cancel: CancellationToken, + connections: TaskTracker, ) -> anyhow::Result<()> { let _ = rustls::crypto::ring::default_provider().install_default(); @@ -205,64 +268,78 @@ async fn start_server_with_listener( let tls_acceptor = tls_acceptor.clone(); let app_state = app_state.clone(); - tokio::spawn(async move { - let res = match tls_acceptor { - Some(tls_acceptor) => { - let tls_stream = match tls_acceptor.accept(tcp_stream).await { - Ok(tls_stream) => tls_stream, - Err(err) => { - tracing::warn!("TLS handshake error: {err:#}"); - return; - } - }; + let cancel = cancel.clone(); + connections.spawn(async move { + let serve = serve_connection(tcp_stream, remote_addr, tls_acceptor, app_state); + tokio::select! { + _ = serve => {} + // Hard-drop the connection when the server is stopped. + _ = cancel.cancelled() => {} + } + }); + } +} - let client_info = { - let (_, server_connection) = tls_stream.get_ref(); - RequestClientInfo { - ip: remote_addr.ip(), - cert: server_connection - .deref() - .deref() - .peer_certificates() - .map(|cert| cert.get(0).unwrap().to_vec()), - } - }; - - Builder::new(TokioExecutor::new()) - .serve_connection( - TokioIo::new(tls_stream), - hyper::service::service_fn(move |mut req: Request| { - req.extensions_mut() - .insert::(client_info.clone()); - req.extensions_mut().insert::(app_state.clone()); - handle_request(req) - }), - ) - .await - } - None => { - Builder::new(TokioExecutor::new()) - .serve_connection( - TokioIo::new(tcp_stream), - hyper::service::service_fn(move |mut req: Request| { - req.extensions_mut().insert::( - RequestClientInfo { - ip: remote_addr.ip(), - cert: None, - }, - ); - req.extensions_mut().insert::(app_state.clone()); - handle_request(req) - }), - ) - .await +async fn serve_connection( + tcp_stream: tokio::net::TcpStream, + remote_addr: SocketAddr, + tls_acceptor: Option, + app_state: AppState, +) { + let res = match tls_acceptor { + Some(tls_acceptor) => { + let tls_stream = match tls_acceptor.accept(tcp_stream).await { + Ok(tls_stream) => tls_stream, + Err(err) => { + tracing::warn!("TLS handshake error: {err:#}"); + return; } }; - if let Err(err) = res { - tracing::warn!("Failed to serve connection: {err:#}"); - } - }); + let client_info = { + let (_, server_connection) = tls_stream.get_ref(); + RequestClientInfo { + ip: remote_addr.ip(), + cert: server_connection + .deref() + .deref() + .peer_certificates() + .map(|cert| cert.get(0).unwrap().to_vec()), + } + }; + + Builder::new(TokioExecutor::new()) + .serve_connection( + TokioIo::new(tls_stream), + hyper::service::service_fn(move |mut req: Request| { + req.extensions_mut() + .insert::(client_info.clone()); + req.extensions_mut().insert::(app_state.clone()); + handle_request(req) + }), + ) + .await + } + None => { + Builder::new(TokioExecutor::new()) + .serve_connection( + TokioIo::new(tcp_stream), + hyper::service::service_fn(move |mut req: Request| { + req.extensions_mut() + .insert::(RequestClientInfo { + ip: remote_addr.ip(), + cert: None, + }); + req.extensions_mut().insert::(app_state.clone()); + handle_request(req) + }), + ) + .await + } + }; + + if let Err(err) = res { + tracing::warn!("Failed to serve connection: {err:#}"); } } @@ -372,7 +449,7 @@ async fn handle_request_inner(mut req: Request) -> Result internal::show(req, state).await, diff --git a/packages/core/src/http/server/v2.rs b/packages/core/src/http/server/v2.rs index cbb36844..bb4365db 100644 --- a/packages/core/src/http/server/v2.rs +++ b/packages/core/src/http/server/v2.rs @@ -38,6 +38,11 @@ pub enum ServerEventV2 { /// The application must answer on `decision_tx`. /// Dropping `decision_tx` results in a 500 response. PrepareUpload { + /// The session ID the upload session will have when the request is + /// accepted. Pre-generated so the application can track the session + /// consistently from the start. + session_id: String, + /// The IP address of the sender. ip: IpAddr, @@ -78,6 +83,30 @@ pub enum ServerEventV2 { /// Why the session ended. reason: SessionEndReasonV2, }, + + /// A prepare-upload request was aborted before a session was created, + /// e.g. the sender disconnected while the application was still deciding. + /// The `decision_tx` of the [ServerEventV2::PrepareUpload] with the same + /// session ID is dead; answering it has no effect. + PrepareUploadAborted { + /// The session ID of the aborted prepare-upload request. + session_id: String, + }, + + /// `POST /api/localsend/v2/cancel` was received for a session this server + /// does not manage. This happens when the remote device cancels a transfer + /// that this application is currently *sending* to it: the session ID is + /// the one issued by the remote device during prepare-upload. + /// + /// The application must verify that `ip` matches the target of the + /// send session before cancelling it. + CancelReceived { + /// The IP address of the remote device requesting the cancellation. + ip: IpAddr, + + /// The session ID as known by the remote device. + session_id: String, + }, } /// The application's decision for a prepare-upload request. @@ -182,11 +211,14 @@ pub(crate) async fn prepare_upload( *slot = Some(SessionStateV2::Pending); } + let session_id = Uuid::new_v4().to_string(); + // Frees the slot again if this request is aborted before a session is created. - let mut pending_guard = PendingSessionGuard::new(v2.clone()); + let mut pending_guard = PendingSessionGuard::new(v2.clone(), session_id.clone()); let (decision_tx, decision_rx) = oneshot::channel(); let event = ServerEventV2::PrepareUpload { + session_id: session_id.clone(), ip: client_info.ip, info: payload.info, files: payload.files.clone(), @@ -233,7 +265,6 @@ pub(crate) async fn prepare_upload( return Ok(res); } - let session_id = Uuid::new_v4().to_string(); let tokens: HashMap = files .iter() .map(|(id, file)| (id.clone(), file.token.clone())) @@ -334,6 +365,7 @@ pub(crate) async fn upload( pub(crate) async fn cancel( req: Request, state: AppState, + client_info: RequestClientInfo, ) -> Result, AppError> { let v2 = require_v2(&state)?; let query = parse_query(req.uri().query()); @@ -342,7 +374,10 @@ pub(crate) async fn cancel( let cancelled = { let mut slot = v2.session.lock().await; match slot.as_ref() { - Some(SessionStateV2::Active(session)) if session.session_id == *session_id => { + Some(SessionStateV2::Active(session)) + if session.session_id == *session_id + && session.sender_ip == client_info.ip => + { *slot = None; true } @@ -359,6 +394,16 @@ pub(crate) async fn cancel( reason: SessionEndReasonV2::Cancelled, }) .await; + } else { + // Not one of our upload sessions: the remote device may be + // cancelling a transfer this application is sending to it. + let _ = v2 + .event_tx + .send(ServerEventV2::CancelReceived { + ip: client_info.ip, + session_id: session_id.clone(), + }) + .await; } } @@ -386,12 +431,17 @@ fn invalid_token_error() -> AppError { /// while the application was still deciding). struct PendingSessionGuard { v2: Arc, + session_id: String, armed: bool, } impl PendingSessionGuard { - fn new(v2: Arc) -> Self { - Self { v2, armed: true } + fn new(v2: Arc, session_id: String) -> Self { + Self { + v2, + session_id, + armed: true, + } } /// Disarms the guard after the pending slot was replaced by an active session. @@ -412,8 +462,15 @@ impl Drop for PendingSessionGuard { return; } let v2 = self.v2.clone(); + let session_id = std::mem::take(&mut self.session_id); tokio::spawn(async move { clear_pending_session(&v2).await; + // The application may still be waiting for a decision; tell it + // that answering is pointless now. + let _ = v2 + .event_tx + .send(ServerEventV2::PrepareUploadAborted { session_id }) + .await; }); } } diff --git a/packages/core/tests/v2_server.rs b/packages/core/tests/v2_server.rs index 8512d829..a8c35256 100644 --- a/packages/core/tests/v2_server.rs +++ b/packages/core/tests/v2_server.rs @@ -90,6 +90,7 @@ async fn start_test_server( let _ = target_tx.send(FileUploadTarget::Path { path: path.clone(), result_tx, + progress_tx: None, }); tokio::spawn(async move { if let Ok(Ok(())) = result_rx.await { @@ -103,6 +104,8 @@ async fn start_test_server( ServerEventV2::SessionEnd { session_id, reason } => { session_ends.lock().await.push((session_id, reason)); } + ServerEventV2::PrepareUploadAborted { .. } => {} + ServerEventV2::CancelReceived { .. } => {} } } } diff --git a/packages/localsend_isolates/lib/isolate.dart b/packages/localsend_isolates/lib/isolate.dart index f358b089..3a50ff45 100644 --- a/packages/localsend_isolates/lib/isolate.dart +++ b/packages/localsend_isolates/lib/isolate.dart @@ -1,11 +1,18 @@ export 'package:localsend_isolates/src/isolate/child/server_isolate.dart' show + HttpServerCancelReceivedEvent, HttpServerEvent, HttpServerFileUploadEvent, + HttpServerFileUploadProgressEvent, HttpServerFileUploadResultEvent, + HttpServerPrepareUploadAbortedEvent, HttpServerPrepareUploadEvent, HttpServerRegisterEvent, - HttpServerSessionEndEvent; + HttpServerSessionEndEvent, + HttpServerShowEvent, + HttpServerStartedEvent, + HttpServerWebFileDownloadEvent, + HttpServerWebPrepareDownloadEvent; export 'package:localsend_isolates/src/isolate/child/sync_provider.dart'; export 'package:localsend_isolates/src/isolate/child/upload_isolate.dart' show diff --git a/packages/localsend_isolates/lib/rust/api/server.dart b/packages/localsend_isolates/lib/rust/api/server.dart index bef8a2ec..cc06da0b 100644 --- a/packages/localsend_isolates/lib/rust/api/server.dart +++ b/packages/localsend_isolates/lib/rust/api/server.dart @@ -49,6 +49,15 @@ Future startServer({ // Rust type: RustOpaqueMoi> abstract class RsHttpServer implements RustOpaqueInterface { + /// Cancels the active upload session, e.g. because the user aborted the + /// transfer on the receiving side. + /// + /// Uploads that are already in progress still run to completion, but new + /// upload requests are rejected and a new session can be created. + /// No [RsServerEvent::SessionEnd] is emitted: the application initiated + /// the cancellation itself. + Future cancelSession({required String sessionId}); + /// Emits server events until the server is stopped. /// Can only be listened to once. /// @@ -56,6 +65,20 @@ abstract class RsHttpServer implements RustOpaqueInterface { /// events are all emitted on the same stream. Stream listen(); + /// Rejects the pending [RsServerEvent::WebFileDownload] event, e.g. because + /// the application failed to resolve a source for the file content. + /// + /// The download request fails with an error response. + /// Does nothing if the download was already answered. + Future rejectFileDownload({required String sessionId, required String fileId}); + + /// Rejects the pending [RsServerEvent::FileUpload] event, e.g. because + /// the application failed to prepare a save target for the file. + /// + /// The upload request fails with an error response and the file is marked + /// as failed. Does nothing if the upload was already answered. + Future rejectFileUpload({required String sessionId, required String fileId}); + /// Answers the pending [RsServerEvent::WebFileDownload] event with the source /// the file content should be read from (either a path or a file descriptor). /// @@ -65,7 +88,10 @@ abstract class RsHttpServer implements RustOpaqueInterface { /// Answers the pending [RsServerEvent::FileUpload] event with the target /// the file should be saved to (either a path or a file descriptor) /// and waits until the file has been received completely. - Future respondFileUpload({required String sessionId, required String fileId, String? path, int? fileDescriptor}); + /// + /// The progress (fraction of [file_size]) is emitted on [sink] + /// while the file is being received. + Stream respondFileUpload({required String sessionId, required String fileId, String? path, int? fileDescriptor, required BigInt fileSize}); /// Answers the pending [RsServerEvent::WebPrepareDownload] event. /// @@ -79,6 +105,7 @@ abstract class RsHttpServer implements RustOpaqueInterface { Future respondPrepareUpload({List? acceptedFileIds}); /// Stops the server. + /// Returns after the listeners are closed, so the port can be bound again. Future stop(); } @@ -146,6 +173,8 @@ sealed class RsServerEvent with _$RsServerEvent { /// A sender requests to upload files via `POST /api/localsend/v2/prepare-upload`. const factory RsServerEvent.prepareUpload({ + /// The session ID the upload session will have when the request is accepted. + required String sessionId, required String ip, required RegisterDtoV2 info, required Map files, @@ -164,6 +193,23 @@ sealed class RsServerEvent with _$RsServerEvent { required SessionEndReasonV2 reason, }) = RsServerEvent_SessionEnd; + /// A prepare-upload request was aborted before a session was created, + /// e.g. the sender disconnected while the application was still deciding. + /// The [RsServerEvent::PrepareUpload] with the same session ID + /// no longer needs to be answered. + const factory RsServerEvent.prepareUploadAborted({ + required String sessionId, + }) = RsServerEvent_PrepareUploadAborted; + + /// `POST /api/localsend/v2/cancel` was received for a session this server + /// does not manage: the remote device cancels a transfer this application + /// is currently *sending* to it. The application must verify that [ip] + /// matches the target of the send session before cancelling it. + const factory RsServerEvent.cancelReceived({ + required String ip, + required String sessionId, + }) = RsServerEvent_CancelReceived; + /// A web client requests to download the shared files via `POST /api/localsend/v2/prepare-download`. /// /// Must be answered with [RsHttpServer::respond_prepare_download]. diff --git a/packages/localsend_isolates/lib/rust/api/server.freezed.dart b/packages/localsend_isolates/lib/rust/api/server.freezed.dart index 6ed90af7..aad44400 100644 --- a/packages/localsend_isolates/lib/rust/api/server.freezed.dart +++ b/packages/localsend_isolates/lib/rust/api/server.freezed.dart @@ -55,14 +55,16 @@ extension RsServerEventPatterns on RsServerEvent { /// } /// ``` -@optionalTypeArgs TResult maybeMap({TResult Function( RsServerEvent_Register value)? register,TResult Function( RsServerEvent_PrepareUpload value)? prepareUpload,TResult Function( RsServerEvent_FileUpload value)? fileUpload,TResult Function( RsServerEvent_SessionEnd value)? sessionEnd,TResult Function( RsServerEvent_WebPrepareDownload value)? webPrepareDownload,TResult Function( RsServerEvent_WebFileDownload value)? webFileDownload,TResult Function( RsServerEvent_Show value)? show_,required TResult orElse(),}){ +@optionalTypeArgs TResult maybeMap({TResult Function( RsServerEvent_Register value)? register,TResult Function( RsServerEvent_PrepareUpload value)? prepareUpload,TResult Function( RsServerEvent_FileUpload value)? fileUpload,TResult Function( RsServerEvent_SessionEnd value)? sessionEnd,TResult Function( RsServerEvent_PrepareUploadAborted value)? prepareUploadAborted,TResult Function( RsServerEvent_CancelReceived value)? cancelReceived,TResult Function( RsServerEvent_WebPrepareDownload value)? webPrepareDownload,TResult Function( RsServerEvent_WebFileDownload value)? webFileDownload,TResult Function( RsServerEvent_Show value)? show_,required TResult orElse(),}){ final _that = this; switch (_that) { case RsServerEvent_Register() when register != null: return register(_that);case RsServerEvent_PrepareUpload() when prepareUpload != null: return prepareUpload(_that);case RsServerEvent_FileUpload() when fileUpload != null: return fileUpload(_that);case RsServerEvent_SessionEnd() when sessionEnd != null: -return sessionEnd(_that);case RsServerEvent_WebPrepareDownload() when webPrepareDownload != null: +return sessionEnd(_that);case RsServerEvent_PrepareUploadAborted() when prepareUploadAborted != null: +return prepareUploadAborted(_that);case RsServerEvent_CancelReceived() when cancelReceived != null: +return cancelReceived(_that);case RsServerEvent_WebPrepareDownload() when webPrepareDownload != null: return webPrepareDownload(_that);case RsServerEvent_WebFileDownload() when webFileDownload != null: return webFileDownload(_that);case RsServerEvent_Show() when show_ != null: return show_(_that);case _: @@ -83,14 +85,16 @@ return show_(_that);case _: /// } /// ``` -@optionalTypeArgs TResult map({required TResult Function( RsServerEvent_Register value) register,required TResult Function( RsServerEvent_PrepareUpload value) prepareUpload,required TResult Function( RsServerEvent_FileUpload value) fileUpload,required TResult Function( RsServerEvent_SessionEnd value) sessionEnd,required TResult Function( RsServerEvent_WebPrepareDownload value) webPrepareDownload,required TResult Function( RsServerEvent_WebFileDownload value) webFileDownload,required TResult Function( RsServerEvent_Show value) show_,}){ +@optionalTypeArgs TResult map({required TResult Function( RsServerEvent_Register value) register,required TResult Function( RsServerEvent_PrepareUpload value) prepareUpload,required TResult Function( RsServerEvent_FileUpload value) fileUpload,required TResult Function( RsServerEvent_SessionEnd value) sessionEnd,required TResult Function( RsServerEvent_PrepareUploadAborted value) prepareUploadAborted,required TResult Function( RsServerEvent_CancelReceived value) cancelReceived,required TResult Function( RsServerEvent_WebPrepareDownload value) webPrepareDownload,required TResult Function( RsServerEvent_WebFileDownload value) webFileDownload,required TResult Function( RsServerEvent_Show value) show_,}){ final _that = this; switch (_that) { case RsServerEvent_Register(): return register(_that);case RsServerEvent_PrepareUpload(): return prepareUpload(_that);case RsServerEvent_FileUpload(): return fileUpload(_that);case RsServerEvent_SessionEnd(): -return sessionEnd(_that);case RsServerEvent_WebPrepareDownload(): +return sessionEnd(_that);case RsServerEvent_PrepareUploadAborted(): +return prepareUploadAborted(_that);case RsServerEvent_CancelReceived(): +return cancelReceived(_that);case RsServerEvent_WebPrepareDownload(): return webPrepareDownload(_that);case RsServerEvent_WebFileDownload(): return webFileDownload(_that);case RsServerEvent_Show(): return show_(_that);} @@ -107,14 +111,16 @@ return show_(_that);} /// } /// ``` -@optionalTypeArgs TResult? mapOrNull({TResult? Function( RsServerEvent_Register value)? register,TResult? Function( RsServerEvent_PrepareUpload value)? prepareUpload,TResult? Function( RsServerEvent_FileUpload value)? fileUpload,TResult? Function( RsServerEvent_SessionEnd value)? sessionEnd,TResult? Function( RsServerEvent_WebPrepareDownload value)? webPrepareDownload,TResult? Function( RsServerEvent_WebFileDownload value)? webFileDownload,TResult? Function( RsServerEvent_Show value)? show_,}){ +@optionalTypeArgs TResult? mapOrNull({TResult? Function( RsServerEvent_Register value)? register,TResult? Function( RsServerEvent_PrepareUpload value)? prepareUpload,TResult? Function( RsServerEvent_FileUpload value)? fileUpload,TResult? Function( RsServerEvent_SessionEnd value)? sessionEnd,TResult? Function( RsServerEvent_PrepareUploadAborted value)? prepareUploadAborted,TResult? Function( RsServerEvent_CancelReceived value)? cancelReceived,TResult? Function( RsServerEvent_WebPrepareDownload value)? webPrepareDownload,TResult? Function( RsServerEvent_WebFileDownload value)? webFileDownload,TResult? Function( RsServerEvent_Show value)? show_,}){ final _that = this; switch (_that) { case RsServerEvent_Register() when register != null: return register(_that);case RsServerEvent_PrepareUpload() when prepareUpload != null: return prepareUpload(_that);case RsServerEvent_FileUpload() when fileUpload != null: return fileUpload(_that);case RsServerEvent_SessionEnd() when sessionEnd != null: -return sessionEnd(_that);case RsServerEvent_WebPrepareDownload() when webPrepareDownload != null: +return sessionEnd(_that);case RsServerEvent_PrepareUploadAborted() when prepareUploadAborted != null: +return prepareUploadAborted(_that);case RsServerEvent_CancelReceived() when cancelReceived != null: +return cancelReceived(_that);case RsServerEvent_WebPrepareDownload() when webPrepareDownload != null: return webPrepareDownload(_that);case RsServerEvent_WebFileDownload() when webFileDownload != null: return webFileDownload(_that);case RsServerEvent_Show() when show_ != null: return show_(_that);case _: @@ -134,13 +140,15 @@ return show_(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen({TResult Function( String ip, RegisterDtoV2 info)? register,TResult Function( String ip, RegisterDtoV2 info, Map files)? prepareUpload,TResult Function( String sessionId, String fileId, FileDto file)? fileUpload,TResult Function( String sessionId, SessionEndReasonV2 reason)? sessionEnd,TResult Function( String ip, String sessionId, String? userAgent)? webPrepareDownload,TResult Function( String sessionId, String fileId, FileDto file)? webFileDownload,TResult Function( List args)? show_,required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen({TResult Function( String ip, RegisterDtoV2 info)? register,TResult Function( String sessionId, String ip, RegisterDtoV2 info, Map files)? prepareUpload,TResult Function( String sessionId, String fileId, FileDto file)? fileUpload,TResult Function( String sessionId, SessionEndReasonV2 reason)? sessionEnd,TResult Function( String sessionId)? prepareUploadAborted,TResult Function( String ip, String sessionId)? cancelReceived,TResult Function( String ip, String sessionId, String? userAgent)? webPrepareDownload,TResult Function( String sessionId, String fileId, FileDto file)? webFileDownload,TResult Function( List args)? show_,required TResult orElse(),}) {final _that = this; switch (_that) { case RsServerEvent_Register() when register != null: return register(_that.ip,_that.info);case RsServerEvent_PrepareUpload() when prepareUpload != null: -return prepareUpload(_that.ip,_that.info,_that.files);case RsServerEvent_FileUpload() when fileUpload != null: +return prepareUpload(_that.sessionId,_that.ip,_that.info,_that.files);case RsServerEvent_FileUpload() when fileUpload != null: return fileUpload(_that.sessionId,_that.fileId,_that.file);case RsServerEvent_SessionEnd() when sessionEnd != null: -return sessionEnd(_that.sessionId,_that.reason);case RsServerEvent_WebPrepareDownload() when webPrepareDownload != null: +return sessionEnd(_that.sessionId,_that.reason);case RsServerEvent_PrepareUploadAborted() when prepareUploadAborted != null: +return prepareUploadAborted(_that.sessionId);case RsServerEvent_CancelReceived() when cancelReceived != null: +return cancelReceived(_that.ip,_that.sessionId);case RsServerEvent_WebPrepareDownload() when webPrepareDownload != null: return webPrepareDownload(_that.ip,_that.sessionId,_that.userAgent);case RsServerEvent_WebFileDownload() when webFileDownload != null: return webFileDownload(_that.sessionId,_that.fileId,_that.file);case RsServerEvent_Show() when show_ != null: return show_(_that.args);case _: @@ -161,13 +169,15 @@ return show_(_that.args);case _: /// } /// ``` -@optionalTypeArgs TResult when({required TResult Function( String ip, RegisterDtoV2 info) register,required TResult Function( String ip, RegisterDtoV2 info, Map files) prepareUpload,required TResult Function( String sessionId, String fileId, FileDto file) fileUpload,required TResult Function( String sessionId, SessionEndReasonV2 reason) sessionEnd,required TResult Function( String ip, String sessionId, String? userAgent) webPrepareDownload,required TResult Function( String sessionId, String fileId, FileDto file) webFileDownload,required TResult Function( List args) show_,}) {final _that = this; +@optionalTypeArgs TResult when({required TResult Function( String ip, RegisterDtoV2 info) register,required TResult Function( String sessionId, String ip, RegisterDtoV2 info, Map files) prepareUpload,required TResult Function( String sessionId, String fileId, FileDto file) fileUpload,required TResult Function( String sessionId, SessionEndReasonV2 reason) sessionEnd,required TResult Function( String sessionId) prepareUploadAborted,required TResult Function( String ip, String sessionId) cancelReceived,required TResult Function( String ip, String sessionId, String? userAgent) webPrepareDownload,required TResult Function( String sessionId, String fileId, FileDto file) webFileDownload,required TResult Function( List args) show_,}) {final _that = this; switch (_that) { case RsServerEvent_Register(): return register(_that.ip,_that.info);case RsServerEvent_PrepareUpload(): -return prepareUpload(_that.ip,_that.info,_that.files);case RsServerEvent_FileUpload(): +return prepareUpload(_that.sessionId,_that.ip,_that.info,_that.files);case RsServerEvent_FileUpload(): return fileUpload(_that.sessionId,_that.fileId,_that.file);case RsServerEvent_SessionEnd(): -return sessionEnd(_that.sessionId,_that.reason);case RsServerEvent_WebPrepareDownload(): +return sessionEnd(_that.sessionId,_that.reason);case RsServerEvent_PrepareUploadAborted(): +return prepareUploadAborted(_that.sessionId);case RsServerEvent_CancelReceived(): +return cancelReceived(_that.ip,_that.sessionId);case RsServerEvent_WebPrepareDownload(): return webPrepareDownload(_that.ip,_that.sessionId,_that.userAgent);case RsServerEvent_WebFileDownload(): return webFileDownload(_that.sessionId,_that.fileId,_that.file);case RsServerEvent_Show(): return show_(_that.args);} @@ -184,13 +194,15 @@ return show_(_that.args);} /// } /// ``` -@optionalTypeArgs TResult? whenOrNull({TResult? Function( String ip, RegisterDtoV2 info)? register,TResult? Function( String ip, RegisterDtoV2 info, Map files)? prepareUpload,TResult? Function( String sessionId, String fileId, FileDto file)? fileUpload,TResult? Function( String sessionId, SessionEndReasonV2 reason)? sessionEnd,TResult? Function( String ip, String sessionId, String? userAgent)? webPrepareDownload,TResult? Function( String sessionId, String fileId, FileDto file)? webFileDownload,TResult? Function( List args)? show_,}) {final _that = this; +@optionalTypeArgs TResult? whenOrNull({TResult? Function( String ip, RegisterDtoV2 info)? register,TResult? Function( String sessionId, String ip, RegisterDtoV2 info, Map files)? prepareUpload,TResult? Function( String sessionId, String fileId, FileDto file)? fileUpload,TResult? Function( String sessionId, SessionEndReasonV2 reason)? sessionEnd,TResult? Function( String sessionId)? prepareUploadAborted,TResult? Function( String ip, String sessionId)? cancelReceived,TResult? Function( String ip, String sessionId, String? userAgent)? webPrepareDownload,TResult? Function( String sessionId, String fileId, FileDto file)? webFileDownload,TResult? Function( List args)? show_,}) {final _that = this; switch (_that) { case RsServerEvent_Register() when register != null: return register(_that.ip,_that.info);case RsServerEvent_PrepareUpload() when prepareUpload != null: -return prepareUpload(_that.ip,_that.info,_that.files);case RsServerEvent_FileUpload() when fileUpload != null: +return prepareUpload(_that.sessionId,_that.ip,_that.info,_that.files);case RsServerEvent_FileUpload() when fileUpload != null: return fileUpload(_that.sessionId,_that.fileId,_that.file);case RsServerEvent_SessionEnd() when sessionEnd != null: -return sessionEnd(_that.sessionId,_that.reason);case RsServerEvent_WebPrepareDownload() when webPrepareDownload != null: +return sessionEnd(_that.sessionId,_that.reason);case RsServerEvent_PrepareUploadAborted() when prepareUploadAborted != null: +return prepareUploadAborted(_that.sessionId);case RsServerEvent_CancelReceived() when cancelReceived != null: +return cancelReceived(_that.ip,_that.sessionId);case RsServerEvent_WebPrepareDownload() when webPrepareDownload != null: return webPrepareDownload(_that.ip,_that.sessionId,_that.userAgent);case RsServerEvent_WebFileDownload() when webFileDownload != null: return webFileDownload(_that.sessionId,_that.fileId,_that.file);case RsServerEvent_Show() when show_ != null: return show_(_that.args);case _: @@ -273,9 +285,11 @@ as RegisterDtoV2, class RsServerEvent_PrepareUpload extends RsServerEvent { - const RsServerEvent_PrepareUpload({required this.ip, required this.info, required final Map files}): _files = files,super._(); + const RsServerEvent_PrepareUpload({required this.sessionId, required this.ip, required this.info, required final Map files}): _files = files,super._(); +/// The session ID the upload session will have when the request is accepted. + final String sessionId; final String ip; final RegisterDtoV2 info; final Map _files; @@ -296,16 +310,16 @@ $RsServerEvent_PrepareUploadCopyWith get copyWith = @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is RsServerEvent_PrepareUpload&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.info, info) || other.info == info)&&const DeepCollectionEquality().equals(other._files, _files)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is RsServerEvent_PrepareUpload&&(identical(other.sessionId, sessionId) || other.sessionId == sessionId)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.info, info) || other.info == info)&&const DeepCollectionEquality().equals(other._files, _files)); } @override -int get hashCode => Object.hash(runtimeType,ip,info,const DeepCollectionEquality().hash(_files)); +int get hashCode => Object.hash(runtimeType,sessionId,ip,info,const DeepCollectionEquality().hash(_files)); @override String toString() { - return 'RsServerEvent.prepareUpload(ip: $ip, info: $info, files: $files)'; + return 'RsServerEvent.prepareUpload(sessionId: $sessionId, ip: $ip, info: $info, files: $files)'; } @@ -316,7 +330,7 @@ abstract mixin class $RsServerEvent_PrepareUploadCopyWith<$Res> implements $RsSe factory $RsServerEvent_PrepareUploadCopyWith(RsServerEvent_PrepareUpload value, $Res Function(RsServerEvent_PrepareUpload) _then) = _$RsServerEvent_PrepareUploadCopyWithImpl; @useResult $Res call({ - String ip, RegisterDtoV2 info, Map files + String sessionId, String ip, RegisterDtoV2 info, Map files }); @@ -333,9 +347,10 @@ class _$RsServerEvent_PrepareUploadCopyWithImpl<$Res> /// Create a copy of RsServerEvent /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') $Res call({Object? ip = null,Object? info = null,Object? files = null,}) { +@pragma('vm:prefer-inline') $Res call({Object? sessionId = null,Object? ip = null,Object? info = null,Object? files = null,}) { return _then(RsServerEvent_PrepareUpload( -ip: null == ip ? _self.ip : ip // ignore: cast_nullable_to_non_nullable +sessionId: null == sessionId ? _self.sessionId : sessionId // ignore: cast_nullable_to_non_nullable +as String,ip: null == ip ? _self.ip : ip // ignore: cast_nullable_to_non_nullable as String,info: null == info ? _self.info : info // ignore: cast_nullable_to_non_nullable as RegisterDtoV2,files: null == files ? _self._files : files // ignore: cast_nullable_to_non_nullable as Map, @@ -486,6 +501,140 @@ as SessionEndReasonV2, /// @nodoc +class RsServerEvent_PrepareUploadAborted extends RsServerEvent { + const RsServerEvent_PrepareUploadAborted({required this.sessionId}): super._(); + + + final String sessionId; + +/// Create a copy of RsServerEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RsServerEvent_PrepareUploadAbortedCopyWith get copyWith => _$RsServerEvent_PrepareUploadAbortedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RsServerEvent_PrepareUploadAborted&&(identical(other.sessionId, sessionId) || other.sessionId == sessionId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,sessionId); + +@override +String toString() { + return 'RsServerEvent.prepareUploadAborted(sessionId: $sessionId)'; +} + + +} + +/// @nodoc +abstract mixin class $RsServerEvent_PrepareUploadAbortedCopyWith<$Res> implements $RsServerEventCopyWith<$Res> { + factory $RsServerEvent_PrepareUploadAbortedCopyWith(RsServerEvent_PrepareUploadAborted value, $Res Function(RsServerEvent_PrepareUploadAborted) _then) = _$RsServerEvent_PrepareUploadAbortedCopyWithImpl; +@useResult +$Res call({ + String sessionId +}); + + + + +} +/// @nodoc +class _$RsServerEvent_PrepareUploadAbortedCopyWithImpl<$Res> + implements $RsServerEvent_PrepareUploadAbortedCopyWith<$Res> { + _$RsServerEvent_PrepareUploadAbortedCopyWithImpl(this._self, this._then); + + final RsServerEvent_PrepareUploadAborted _self; + final $Res Function(RsServerEvent_PrepareUploadAborted) _then; + +/// Create a copy of RsServerEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? sessionId = null,}) { + return _then(RsServerEvent_PrepareUploadAborted( +sessionId: null == sessionId ? _self.sessionId : sessionId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class RsServerEvent_CancelReceived extends RsServerEvent { + const RsServerEvent_CancelReceived({required this.ip, required this.sessionId}): super._(); + + + final String ip; + final String sessionId; + +/// Create a copy of RsServerEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RsServerEvent_CancelReceivedCopyWith get copyWith => _$RsServerEvent_CancelReceivedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RsServerEvent_CancelReceived&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.sessionId, sessionId) || other.sessionId == sessionId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,ip,sessionId); + +@override +String toString() { + return 'RsServerEvent.cancelReceived(ip: $ip, sessionId: $sessionId)'; +} + + +} + +/// @nodoc +abstract mixin class $RsServerEvent_CancelReceivedCopyWith<$Res> implements $RsServerEventCopyWith<$Res> { + factory $RsServerEvent_CancelReceivedCopyWith(RsServerEvent_CancelReceived value, $Res Function(RsServerEvent_CancelReceived) _then) = _$RsServerEvent_CancelReceivedCopyWithImpl; +@useResult +$Res call({ + String ip, String sessionId +}); + + + + +} +/// @nodoc +class _$RsServerEvent_CancelReceivedCopyWithImpl<$Res> + implements $RsServerEvent_CancelReceivedCopyWith<$Res> { + _$RsServerEvent_CancelReceivedCopyWithImpl(this._self, this._then); + + final RsServerEvent_CancelReceived _self; + final $Res Function(RsServerEvent_CancelReceived) _then; + +/// Create a copy of RsServerEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? ip = null,Object? sessionId = null,}) { + return _then(RsServerEvent_CancelReceived( +ip: null == ip ? _self.ip : ip // ignore: cast_nullable_to_non_nullable +as String,sessionId: null == sessionId ? _self.sessionId : sessionId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + class RsServerEvent_WebPrepareDownload extends RsServerEvent { const RsServerEvent_WebPrepareDownload({required this.ip, required this.sessionId, this.userAgent}): super._(); diff --git a/packages/localsend_isolates/lib/rust/frb_generated.dart b/packages/localsend_isolates/lib/rust/frb_generated.dart index eb4fe870..d7a6676b 100644 --- a/packages/localsend_isolates/lib/rust/frb_generated.dart +++ b/packages/localsend_isolates/lib/rust/frb_generated.dart @@ -72,7 +72,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => -1029282510; + int get rustContentHash => 1916764705; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( stem: 'rust_lib_localsend_app', @@ -152,8 +152,14 @@ abstract class RustLibApi extends BaseApi { required RsCancellationToken cancelToken, }); + Future crateApiServerRsHttpServerCancelSession({required RsHttpServer that, required String sessionId}); + Stream crateApiServerRsHttpServerListen({required RsHttpServer that}); + Future crateApiServerRsHttpServerRejectFileDownload({required RsHttpServer that, required String sessionId, required String fileId}); + + Future crateApiServerRsHttpServerRejectFileUpload({required RsHttpServer that, required String sessionId, required String fileId}); + Future crateApiServerRsHttpServerRespondFileDownload({ required RsHttpServer that, required String sessionId, @@ -162,12 +168,13 @@ abstract class RustLibApi extends BaseApi { int? fileDescriptor, }); - Future crateApiServerRsHttpServerRespondFileUpload({ + Stream crateApiServerRsHttpServerRespondFileUpload({ required RsHttpServer that, required String sessionId, required String fileId, String? path, int? fileDescriptor, + required BigInt fileSize, }); Future crateApiServerRsHttpServerRespondPrepareDownload({required RsHttpServer that, required String sessionId, required bool accept}); @@ -670,6 +677,32 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ], ); + @override + Future crateApiServerRsHttpServerCancelSession({required RsHttpServer that, required String sessionId}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer(that, serializer); + sse_encode_String(sessionId, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 11, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiServerRsHttpServerCancelSessionConstMeta, + argValues: [that, sessionId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiServerRsHttpServerCancelSessionConstMeta => const TaskConstMeta( + debugName: 'RsHttpServer_cancel_session', + argNames: ['that', 'sessionId'], + ); + @override Stream crateApiServerRsHttpServerListen({required RsHttpServer that}) { final sink = RustStreamSink(); @@ -680,7 +713,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer(that, serializer); sse_encode_StreamSink_rs_server_event_Sse(sink, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 11, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 12, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -700,6 +733,60 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ['that', 'sink'], ); + @override + Future crateApiServerRsHttpServerRejectFileDownload({required RsHttpServer that, required String sessionId, required String fileId}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer(that, serializer); + sse_encode_String(sessionId, serializer); + sse_encode_String(fileId, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 13, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiServerRsHttpServerRejectFileDownloadConstMeta, + argValues: [that, sessionId, fileId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiServerRsHttpServerRejectFileDownloadConstMeta => const TaskConstMeta( + debugName: 'RsHttpServer_reject_file_download', + argNames: ['that', 'sessionId', 'fileId'], + ); + + @override + Future crateApiServerRsHttpServerRejectFileUpload({required RsHttpServer that, required String sessionId, required String fileId}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer(that, serializer); + sse_encode_String(sessionId, serializer); + sse_encode_String(fileId, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiServerRsHttpServerRejectFileUploadConstMeta, + argValues: [that, sessionId, fileId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiServerRsHttpServerRejectFileUploadConstMeta => const TaskConstMeta( + debugName: 'RsHttpServer_reject_file_upload', + argNames: ['that', 'sessionId', 'fileId'], + ); + @override Future crateApiServerRsHttpServerRespondFileDownload({ required RsHttpServer that, @@ -717,7 +804,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(fileId, serializer); sse_encode_opt_String(path, serializer); sse_encode_opt_box_autoadd_i_32(fileDescriptor, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 12, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 15, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -736,38 +823,45 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); @override - Future crateApiServerRsHttpServerRespondFileUpload({ + Stream crateApiServerRsHttpServerRespondFileUpload({ required RsHttpServer that, required String sessionId, required String fileId, String? path, int? fileDescriptor, + required BigInt fileSize, }) { - return handler.executeNormal( - NormalTask( - callFfi: (port_) { - final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer(that, serializer); - sse_encode_String(sessionId, serializer); - sse_encode_String(fileId, serializer); - sse_encode_opt_String(path, serializer); - sse_encode_opt_box_autoadd_i_32(fileDescriptor, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 13, port: port_); - }, - codec: SseCodec( - decodeSuccessData: sse_decode_unit, - decodeErrorData: sse_decode_AnyhowException, + final sink = RustStreamSink(); + unawaited( + handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer(that, serializer); + sse_encode_StreamSink_f_64_Sse(sink, serializer); + sse_encode_String(sessionId, serializer); + sse_encode_String(fileId, serializer); + sse_encode_opt_String(path, serializer); + sse_encode_opt_box_autoadd_i_32(fileDescriptor, serializer); + sse_encode_u_64(fileSize, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiServerRsHttpServerRespondFileUploadConstMeta, + argValues: [that, sink, sessionId, fileId, path, fileDescriptor, fileSize], + apiImpl: this, ), - constMeta: kCrateApiServerRsHttpServerRespondFileUploadConstMeta, - argValues: [that, sessionId, fileId, path, fileDescriptor], - apiImpl: this, ), ); + return sink.stream; } TaskConstMeta get kCrateApiServerRsHttpServerRespondFileUploadConstMeta => const TaskConstMeta( debugName: 'RsHttpServer_respond_file_upload', - argNames: ['that', 'sessionId', 'fileId', 'path', 'fileDescriptor'], + argNames: ['that', 'sink', 'sessionId', 'fileId', 'path', 'fileDescriptor', 'fileSize'], ); @override @@ -779,7 +873,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer(that, serializer); sse_encode_String(sessionId, serializer); sse_encode_bool(accept, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -805,7 +899,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer(that, serializer); sse_encode_opt_list_String(acceptedFileIds, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 15, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -830,7 +924,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer(that, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 19, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -855,7 +949,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRTCFileReceiver(that, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 20, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -883,7 +977,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRTCFileReceiver(that, serializer); sse_encode_StreamSink_list_prim_u_8_strict_Sse(sink, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 21, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -911,7 +1005,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRTCFileSender(that, serializer); sse_encode_list_prim_u_8_loose(data, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 19, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 22, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -936,7 +1030,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRTCReceiveController(that, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 20, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -964,7 +1058,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRTCReceiveController(that, serializer); sse_encode_StreamSink_rtc_file_error_Sse(sink, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 21, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 24, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -991,7 +1085,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRTCReceiveController(that, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 22, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 25, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_list_file_dto, @@ -1019,7 +1113,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRTCReceiveController(that, serializer); sse_encode_StreamSink_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRTCFileReceiver_Sse(sink, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 26, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1049,7 +1143,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRTCReceiveController(that, serializer); sse_encode_StreamSink_rtc_status_Sse(sink, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 24, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 27, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1077,7 +1171,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRTCReceiveController(that, serializer); sse_encode_box_autoadd_rtc_send_file_response(status, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 25, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1103,7 +1197,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRTCReceiveController(that, serializer); sse_encode_String(pin, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 26, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 29, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1129,7 +1223,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRTCReceiveController(that, serializer); sse_encode_Set_String_None(selection, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 27, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1157,7 +1251,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRTCSendController(that, serializer); sse_encode_StreamSink_rtc_file_error_Sse(sink, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 31, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1184,7 +1278,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRTCSendController(that, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 29, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 32, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_Set_String_None, @@ -1212,7 +1306,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRTCSendController(that, serializer); sse_encode_StreamSink_rtc_status_Sse(sink, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 33, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1240,7 +1334,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRTCSendController(that, serializer); sse_encode_String(fileId, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 31, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 34, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRTCFileSender, @@ -1266,7 +1360,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRTCSendController(that, serializer); sse_encode_String(pin, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 32, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 35, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1305,7 +1399,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { onConnection, serializer, ); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 33, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 36, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1331,7 +1425,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 34)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 37)!; }, codec: SseCodec( decodeSuccessData: sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsCancellationToken, @@ -1359,7 +1453,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(cert, serializer); sse_encode_ls_http_client_version(version, serializer); sse_encode_opt_box_autoadd_u_32(timeoutMs, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 35)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 38)!; }, codec: SseCodec( decodeSuccessData: sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpClient, @@ -1383,7 +1477,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { NormalTask( callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 36, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 39, port: port_); }, codec: SseCodec( decodeSuccessData: @@ -1408,7 +1502,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { NormalTask( callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 37, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 40, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1432,7 +1526,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { NormalTask( callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 38, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 41, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_key_pair, @@ -1477,7 +1571,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_opt_String(pin, serializer); sse_encode_opt_box_autoadd_web_send_params(webSend, serializer); sse_encode_opt_String(showToken, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 39, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 42, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer, @@ -1503,7 +1597,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(cert, serializer); sse_encode_String(publicKey, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 40, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 43, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2445,9 +2539,10 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); case 1: return RsServerEvent_PrepareUpload( - ip: dco_decode_String(raw[1]), - info: dco_decode_box_autoadd_register_dto_v_2(raw[2]), - files: dco_decode_Map_String_file_dto_None(raw[3]), + sessionId: dco_decode_String(raw[1]), + ip: dco_decode_String(raw[2]), + info: dco_decode_box_autoadd_register_dto_v_2(raw[3]), + files: dco_decode_Map_String_file_dto_None(raw[4]), ); case 2: return RsServerEvent_FileUpload( @@ -2461,18 +2556,27 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { reason: dco_decode_session_end_reason_v_2(raw[2]), ); case 4: + return RsServerEvent_PrepareUploadAborted( + sessionId: dco_decode_String(raw[1]), + ); + case 5: + return RsServerEvent_CancelReceived( + ip: dco_decode_String(raw[1]), + sessionId: dco_decode_String(raw[2]), + ); + case 6: return RsServerEvent_WebPrepareDownload( ip: dco_decode_String(raw[1]), sessionId: dco_decode_String(raw[2]), userAgent: dco_decode_opt_String(raw[3]), ); - case 5: + case 7: return RsServerEvent_WebFileDownload( sessionId: dco_decode_String(raw[1]), fileId: dco_decode_String(raw[2]), file: dco_decode_box_autoadd_file_dto(raw[3]), ); - case 6: + case 8: return RsServerEvent_Show( args: dco_decode_list_String(raw[1]), ); @@ -3587,10 +3691,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_info = sse_decode_box_autoadd_register_dto_v_2(deserializer); return RsServerEvent_Register(ip: var_ip, info: var_info); case 1: + var var_sessionId = sse_decode_String(deserializer); var var_ip = sse_decode_String(deserializer); var var_info = sse_decode_box_autoadd_register_dto_v_2(deserializer); var var_files = sse_decode_Map_String_file_dto_None(deserializer); - return RsServerEvent_PrepareUpload(ip: var_ip, info: var_info, files: var_files); + return RsServerEvent_PrepareUpload(sessionId: var_sessionId, ip: var_ip, info: var_info, files: var_files); case 2: var var_sessionId = sse_decode_String(deserializer); var var_fileId = sse_decode_String(deserializer); @@ -3601,16 +3706,23 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_reason = sse_decode_session_end_reason_v_2(deserializer); return RsServerEvent_SessionEnd(sessionId: var_sessionId, reason: var_reason); case 4: + var var_sessionId = sse_decode_String(deserializer); + return RsServerEvent_PrepareUploadAborted(sessionId: var_sessionId); + case 5: + var var_ip = sse_decode_String(deserializer); + var var_sessionId = sse_decode_String(deserializer); + return RsServerEvent_CancelReceived(ip: var_ip, sessionId: var_sessionId); + case 6: var var_ip = sse_decode_String(deserializer); var var_sessionId = sse_decode_String(deserializer); var var_userAgent = sse_decode_opt_String(deserializer); return RsServerEvent_WebPrepareDownload(ip: var_ip, sessionId: var_sessionId, userAgent: var_userAgent); - case 5: + case 7: var var_sessionId = sse_decode_String(deserializer); var var_fileId = sse_decode_String(deserializer); var var_file = sse_decode_box_autoadd_file_dto(deserializer); return RsServerEvent_WebFileDownload(sessionId: var_sessionId, fileId: var_fileId, file: var_file); - case 6: + case 8: var var_args = sse_decode_list_String(deserializer); return RsServerEvent_Show(args: var_args); default: @@ -4726,8 +4838,9 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_i_32(0, serializer); sse_encode_String(ip, serializer); sse_encode_box_autoadd_register_dto_v_2(info, serializer); - case RsServerEvent_PrepareUpload(ip: final ip, info: final info, files: final files): + case RsServerEvent_PrepareUpload(sessionId: final sessionId, ip: final ip, info: final info, files: final files): sse_encode_i_32(1, serializer); + sse_encode_String(sessionId, serializer); sse_encode_String(ip, serializer); sse_encode_box_autoadd_register_dto_v_2(info, serializer); sse_encode_Map_String_file_dto_None(files, serializer); @@ -4740,18 +4853,25 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_i_32(3, serializer); sse_encode_String(sessionId, serializer); sse_encode_session_end_reason_v_2(reason, serializer); - case RsServerEvent_WebPrepareDownload(ip: final ip, sessionId: final sessionId, userAgent: final userAgent): + case RsServerEvent_PrepareUploadAborted(sessionId: final sessionId): sse_encode_i_32(4, serializer); + sse_encode_String(sessionId, serializer); + case RsServerEvent_CancelReceived(ip: final ip, sessionId: final sessionId): + sse_encode_i_32(5, serializer); + sse_encode_String(ip, serializer); + sse_encode_String(sessionId, serializer); + case RsServerEvent_WebPrepareDownload(ip: final ip, sessionId: final sessionId, userAgent: final userAgent): + sse_encode_i_32(6, serializer); sse_encode_String(ip, serializer); sse_encode_String(sessionId, serializer); sse_encode_opt_String(userAgent, serializer); case RsServerEvent_WebFileDownload(sessionId: final sessionId, fileId: final fileId, file: final file): - sse_encode_i_32(5, serializer); + sse_encode_i_32(7, serializer); sse_encode_String(sessionId, serializer); sse_encode_String(fileId, serializer); sse_encode_box_autoadd_file_dto(file, serializer); case RsServerEvent_Show(args: final args): - sse_encode_i_32(6, serializer); + sse_encode_i_32(8, serializer); sse_encode_list_String(args, serializer); } } @@ -5097,6 +5217,16 @@ class RsHttpServerImpl extends RustOpaque implements RsHttpServer { rustArcDecrementStrongCountPtr: RustLib.instance.api.rust_arc_decrement_strong_count_RsHttpServerPtr, ); + /// Cancels the active upload session, e.g. because the user aborted the + /// transfer on the receiving side. + /// + /// Uploads that are already in progress still run to completion, but new + /// upload requests are rejected and a new session can be created. + /// No [RsServerEvent::SessionEnd] is emitted: the application initiated + /// the cancellation itself. + Future cancelSession({required String sessionId}) => + RustLib.instance.api.crateApiServerRsHttpServerCancelSession(that: this, sessionId: sessionId); + /// Emits server events until the server is stopped. /// Can only be listened to once. /// @@ -5106,6 +5236,22 @@ class RsHttpServerImpl extends RustOpaque implements RsHttpServer { that: this, ); + /// Rejects the pending [RsServerEvent::WebFileDownload] event, e.g. because + /// the application failed to resolve a source for the file content. + /// + /// The download request fails with an error response. + /// Does nothing if the download was already answered. + Future rejectFileDownload({required String sessionId, required String fileId}) => + RustLib.instance.api.crateApiServerRsHttpServerRejectFileDownload(that: this, sessionId: sessionId, fileId: fileId); + + /// Rejects the pending [RsServerEvent::FileUpload] event, e.g. because + /// the application failed to prepare a save target for the file. + /// + /// The upload request fails with an error response and the file is marked + /// as failed. Does nothing if the upload was already answered. + Future rejectFileUpload({required String sessionId, required String fileId}) => + RustLib.instance.api.crateApiServerRsHttpServerRejectFileUpload(that: this, sessionId: sessionId, fileId: fileId); + /// Answers the pending [RsServerEvent::WebFileDownload] event with the source /// the file content should be read from (either a path or a file descriptor). /// @@ -5116,8 +5262,23 @@ class RsHttpServerImpl extends RustOpaque implements RsHttpServer { /// Answers the pending [RsServerEvent::FileUpload] event with the target /// the file should be saved to (either a path or a file descriptor) /// and waits until the file has been received completely. - Future respondFileUpload({required String sessionId, required String fileId, String? path, int? fileDescriptor}) => RustLib.instance.api - .crateApiServerRsHttpServerRespondFileUpload(that: this, sessionId: sessionId, fileId: fileId, path: path, fileDescriptor: fileDescriptor); + /// + /// The progress (fraction of [file_size]) is emitted on [sink] + /// while the file is being received. + Stream respondFileUpload({ + required String sessionId, + required String fileId, + String? path, + int? fileDescriptor, + required BigInt fileSize, + }) => RustLib.instance.api.crateApiServerRsHttpServerRespondFileUpload( + that: this, + sessionId: sessionId, + fileId: fileId, + path: path, + fileDescriptor: fileDescriptor, + fileSize: fileSize, + ); /// Answers the pending [RsServerEvent::WebPrepareDownload] event. /// @@ -5133,6 +5294,7 @@ class RsHttpServerImpl extends RustOpaque implements RsHttpServer { RustLib.instance.api.crateApiServerRsHttpServerRespondPrepareUpload(that: this, acceptedFileIds: acceptedFileIds); /// Stops the server. + /// Returns after the listeners are closed, so the port can be bound again. Future stop() => RustLib.instance.api.crateApiServerRsHttpServerStop( that: this, ); diff --git a/packages/localsend_isolates/lib/src/isolate/child/server_isolate.dart b/packages/localsend_isolates/lib/src/isolate/child/server_isolate.dart index 4d6c5a13..178b257c 100644 --- a/packages/localsend_isolates/lib/src/isolate/child/server_isolate.dart +++ b/packages/localsend_isolates/lib/src/isolate/child/server_isolate.dart @@ -36,6 +36,7 @@ class HttpServerStartTask implements BaseHttpServerTask { } /// Stops the HTTP server. +/// The stream of this task completes once the server has released the port. class HttpServerStopTask implements BaseHttpServerTask {} /// Answers a pending [HttpServerPrepareUploadEvent]. @@ -53,19 +54,52 @@ class HttpServerPrepareUploadDecisionTask implements BaseHttpServerTask { /// should be saved to: either a file [path] or a writable [fileDescriptor] (Android). /// /// The file is written by the Rust server itself. -/// A [HttpServerFileUploadResultEvent] is emitted on the stream of this task -/// once the file has been received completely (or failed). +/// [HttpServerFileUploadProgressEvent]s are emitted on the stream of this task +/// while the file is being received, followed by a final +/// [HttpServerFileUploadResultEvent] once the file has been received +/// completely (or failed). class HttpServerFileUploadTargetTask implements BaseHttpServerTask { final String sessionId; final String fileId; final String? path; final int? fileDescriptor; + /// The expected file size in bytes, used to compute the progress. + final int fileSize; + HttpServerFileUploadTargetTask({ required this.sessionId, required this.fileId, required this.path, required this.fileDescriptor, + required this.fileSize, + }); +} + +/// Rejects a pending [HttpServerFileUploadEvent], e.g. because preparing the +/// save target for the file failed. The upload request fails with an error +/// response and the file is marked as failed; the session itself continues. +/// Does nothing if the upload was already answered with a +/// [HttpServerFileUploadTargetTask]. +class HttpServerRejectFileUploadTask implements BaseHttpServerTask { + final String sessionId; + final String fileId; + + HttpServerRejectFileUploadTask({ + required this.sessionId, + required this.fileId, + }); +} + +/// Cancels the active upload session, e.g. because the user aborted the +/// transfer on the receiving side. Uploads that are already in progress still +/// run to completion, but new upload requests are rejected and a new session +/// can be created. No [HttpServerSessionEndEvent] is emitted. +class HttpServerCancelSessionTask implements BaseHttpServerTask { + final String sessionId; + + HttpServerCancelSessionTask({ + required this.sessionId, }); } @@ -100,9 +134,27 @@ class HttpServerFileDownloadTargetTask implements BaseHttpServerTask { }); } +/// Rejects a pending [HttpServerWebFileDownloadEvent], e.g. because no source +/// for the file content could be resolved. The download request fails with an +/// error response. Does nothing if the download was already answered with a +/// [HttpServerFileDownloadTargetTask]. +class HttpServerRejectFileDownloadTask implements BaseHttpServerTask { + final String sessionId; + final String fileId; + + HttpServerRejectFileDownloadTask({ + required this.sessionId, + required this.fileId, + }); +} + /// A message sent from the server isolate to the main isolate. sealed class HttpServerEvent {} +/// The server has been started and is listening. +/// Always the first event emitted by a [HttpServerStartTask]. +class HttpServerStartedEvent extends HttpServerEvent {} + /// A device registered itself on this server. class HttpServerRegisterEvent extends HttpServerEvent { final String ip; @@ -117,11 +169,14 @@ class HttpServerRegisterEvent extends HttpServerEvent { /// A sender requests to upload files. /// Must be answered with a [HttpServerPrepareUploadDecisionTask]. class HttpServerPrepareUploadEvent extends HttpServerEvent { + /// The session ID the upload session will have when the request is accepted. + final String sessionId; final String ip; final RegisterDtoV2 info; final Map files; HttpServerPrepareUploadEvent({ + required this.sessionId, required this.ip, required this.info, required this.files, @@ -142,6 +197,19 @@ class HttpServerFileUploadEvent extends HttpServerEvent { }); } +/// The progress of a [HttpServerFileUploadTargetTask] as a fraction (0.0 to 1.0). +class HttpServerFileUploadProgressEvent extends HttpServerEvent { + final String sessionId; + final String fileId; + final double progress; + + HttpServerFileUploadProgressEvent({ + required this.sessionId, + required this.fileId, + required this.progress, + }); +} + /// The result of a [HttpServerFileUploadTargetTask]. class HttpServerFileUploadResultEvent extends HttpServerEvent { final String sessionId; @@ -168,6 +236,30 @@ class HttpServerSessionEndEvent extends HttpServerEvent { }); } +/// A prepare-upload request was aborted before a session was created, +/// e.g. the sender disconnected while the application was still deciding. +/// The [HttpServerPrepareUploadEvent] with the same [sessionId] +/// no longer needs to be answered. +class HttpServerPrepareUploadAbortedEvent extends HttpServerEvent { + final String sessionId; + + HttpServerPrepareUploadAbortedEvent({required this.sessionId}); +} + +/// The remote device cancels a transfer this application is currently +/// *sending* to it. [sessionId] is the session ID issued by the remote device +/// during prepare-upload. The application must verify that [ip] matches the +/// target of the send session before cancelling it. +class HttpServerCancelReceivedEvent extends HttpServerEvent { + final String ip; + final String sessionId; + + HttpServerCancelReceivedEvent({ + required this.ip, + required this.sessionId, + }); +} + /// A web client requests to download the shared files. /// Must be answered with a [HttpServerPrepareDownloadDecisionTask]. class HttpServerWebPrepareDownloadEvent extends HttpServerEvent { @@ -220,25 +312,45 @@ Future setupHttpServerIsolate( switch (task.data) { case HttpServerStartTask startTask: final syncState = ref.read(syncProvider); - final events = await ref - .read(httpServerProvider) - .start( - port: syncState.port, - tls: syncState.protocol == ProtocolType.https - ? TlsConfig( - cert: syncState.securityContext.certificate, - privateKey: syncState.securityContext.privateKey, - ) - : null, - alias: syncState.alias, - version: protocolVersion, - deviceModel: syncState.deviceInfo.deviceModel, - deviceType: syncState.deviceInfo.deviceType.toRust(), - fingerprint: syncState.securityContext.certificateHash, - pin: startTask.pin, - webSend: startTask.webSend, - showToken: startTask.showToken, - ); + final Stream events; + try { + events = await ref + .read(httpServerProvider) + .start( + port: syncState.port, + tls: syncState.protocol == ProtocolType.https + ? TlsConfig( + cert: syncState.securityContext.certificate, + privateKey: syncState.securityContext.privateKey, + ) + : null, + alias: syncState.alias, + version: protocolVersion, + deviceModel: syncState.deviceInfo.deviceModel, + deviceType: syncState.deviceInfo.deviceType.toRust(), + fingerprint: syncState.securityContext.certificateHash, + pin: startTask.pin, + webSend: startTask.webSend, + showToken: startTask.showToken, + ); + } catch (e) { + // Starting failed (e.g. the port is already in use). + // The error must be sendable across the isolate boundary. + sendToMain( + IsolateTaskStreamResult.error( + id: task.id, + error: e.humanErrorMessage, + ), + ); + return; + } + + sendToMain( + IsolateTaskStreamResult.event( + id: task.id, + data: HttpServerStartedEvent(), + ), + ); try { await for (final event in events) { @@ -247,7 +359,8 @@ Future setupHttpServerIsolate( id: task.id, data: switch (event) { RsServerEvent_Register(:final ip, :final info) => HttpServerRegisterEvent(ip: ip, info: info), - RsServerEvent_PrepareUpload(:final ip, :final info, :final files) => HttpServerPrepareUploadEvent( + RsServerEvent_PrepareUpload(:final sessionId, :final ip, :final info, :final files) => HttpServerPrepareUploadEvent( + sessionId: sessionId, ip: ip, info: info, files: files, @@ -261,6 +374,13 @@ Future setupHttpServerIsolate( sessionId: sessionId, reason: reason, ), + RsServerEvent_PrepareUploadAborted(:final sessionId) => HttpServerPrepareUploadAbortedEvent( + sessionId: sessionId, + ), + RsServerEvent_CancelReceived(:final ip, :final sessionId) => HttpServerCancelReceivedEvent( + ip: ip, + sessionId: sessionId, + ), RsServerEvent_WebPrepareDownload(:final ip, :final sessionId, :final userAgent) => HttpServerWebPrepareDownloadEvent( ip: ip, sessionId: sessionId, @@ -286,6 +406,11 @@ Future setupHttpServerIsolate( return; case HttpServerStopTask _: await ref.read(httpServerProvider).stop(); + sendToMain( + IsolateTaskStreamResult.done( + id: task.id, + ), + ); return; case HttpServerPrepareUploadDecisionTask decisionTask: await ref.read(httpServerProvider).respondPrepareUpload(acceptedFileIds: decisionTask.acceptedFileIds); @@ -293,14 +418,27 @@ Future setupHttpServerIsolate( case HttpServerFileUploadTargetTask targetTask: String? error; try { - await ref + final progressStream = ref .read(httpServerProvider) .respondFileUpload( sessionId: targetTask.sessionId, fileId: targetTask.fileId, path: targetTask.path, fileDescriptor: targetTask.fileDescriptor, + fileSize: targetTask.fileSize, ); + await for (final progress in progressStream) { + sendToMain( + IsolateTaskStreamResult.event( + id: task.id, + data: HttpServerFileUploadProgressEvent( + sessionId: targetTask.sessionId, + fileId: targetTask.fileId, + progress: progress, + ), + ), + ); + } } catch (e) { error = e.humanErrorMessage; } @@ -321,6 +459,17 @@ Future setupHttpServerIsolate( ), ); return; + case HttpServerRejectFileUploadTask rejectTask: + await ref + .read(httpServerProvider) + .rejectFileUpload( + sessionId: rejectTask.sessionId, + fileId: rejectTask.fileId, + ); + return; + case HttpServerCancelSessionTask cancelTask: + await ref.read(httpServerProvider).cancelSession(sessionId: cancelTask.sessionId); + return; case HttpServerPrepareDownloadDecisionTask decisionTask: await ref .read(httpServerProvider) @@ -339,6 +488,14 @@ Future setupHttpServerIsolate( fileDescriptor: targetTask.fileDescriptor, ); return; + case HttpServerRejectFileDownloadTask rejectTask: + await ref + .read(httpServerProvider) + .rejectFileDownload( + sessionId: rejectTask.sessionId, + fileId: rejectTask.fileId, + ); + return; } }, ); diff --git a/packages/localsend_isolates/lib/src/isolate/parent/actions.dart b/packages/localsend_isolates/lib/src/isolate/parent/actions.dart index 2c6f7d8e..7e30e502 100644 --- a/packages/localsend_isolates/lib/src/isolate/parent/actions.dart +++ b/packages/localsend_isolates/lib/src/isolate/parent/actions.dart @@ -228,22 +228,21 @@ class IsolateHttpServerStartAction extends ReduxActionWithResult { +/// Stops the HTTP server. +/// Completes once the server has released the port, so the port can be bound again. +class IsolateHttpServerStopAction extends AsyncReduxAction { @override - ParentIsolateState reduce() { + Future reduce() async { final connection = state.httpServer; if (connection == null) { throw StateError('httpServer is not initialized'); } - connection.sendToIsolate( - SendToIsolateData( - syncState: null, - data: IsolateTask( - data: HttpServerStopTask(), - ), - ), - ); + await connection + .sendWrappedTaskAndListenStream( + task: HttpServerStopTask(), + ) + .drain(); return state; } @@ -283,6 +282,7 @@ class IsolateHttpServerPrepareUploadDecisionAction extends ReduxAction> { @@ -290,12 +290,16 @@ class IsolateHttpServerFileUploadTargetAction extends ReduxActionWithResult _awaitResult(Stream events) async { await for (final event in events) { - if (event case HttpServerFileUploadResultEvent(:final error)) { - if (error != null) { - throw HttpServerFileUploadException(error); - } - return; + switch (event) { + case HttpServerFileUploadProgressEvent(:final progress): + onProgress?.call(progress); + case HttpServerFileUploadResultEvent(:final error): + if (error != null) { + throw HttpServerFileUploadException(error); + } + return; + default: + break; } } @@ -331,6 +341,75 @@ class IsolateHttpServerFileUploadTargetAction extends ReduxActionWithResult { + final String sessionId; + final String fileId; + + IsolateHttpServerRejectFileUploadAction({ + required this.sessionId, + required this.fileId, + }); + + @override + ParentIsolateState reduce() { + final connection = state.httpServer; + if (connection == null) { + throw StateError('httpServer is not initialized'); + } + + connection.sendToIsolate( + SendToIsolateData( + syncState: null, + data: IsolateTask( + data: HttpServerRejectFileUploadTask( + sessionId: sessionId, + fileId: fileId, + ), + ), + ), + ); + + return state; + } +} + +/// Cancels the active upload session of the HTTP server, e.g. because the +/// user aborted the transfer on the receiving side. +/// No [HttpServerSessionEndEvent] is emitted. +class IsolateHttpServerCancelSessionAction extends ReduxAction { + final String sessionId; + + IsolateHttpServerCancelSessionAction({ + required this.sessionId, + }); + + @override + ParentIsolateState reduce() { + final connection = state.httpServer; + if (connection == null) { + throw StateError('httpServer is not initialized'); + } + + connection.sendToIsolate( + SendToIsolateData( + syncState: null, + data: IsolateTask( + data: HttpServerCancelSessionTask( + sessionId: sessionId, + ), + ), + ), + ); + + return state; + } +} + /// Answers a pending [HttpServerWebPrepareDownloadEvent]. class IsolateHttpServerPrepareDownloadDecisionAction extends ReduxAction { final String sessionId; @@ -406,6 +485,43 @@ class IsolateHttpServerFileDownloadTargetAction extends ReduxAction { + final String sessionId; + final String fileId; + + IsolateHttpServerRejectFileDownloadAction({ + required this.sessionId, + required this.fileId, + }); + + @override + ParentIsolateState reduce() { + final connection = state.httpServer; + if (connection == null) { + throw StateError('httpServer is not initialized'); + } + + connection.sendToIsolate( + SendToIsolateData( + syncState: null, + data: IsolateTask( + data: HttpServerRejectFileDownloadTask( + sessionId: sessionId, + fileId: fileId, + ), + ), + ), + ); + + return state; + } +} + /// Saving a file received by the HTTP server failed. class HttpServerFileUploadException implements Exception { final String message; diff --git a/packages/localsend_isolates/lib/src/isolate/parent/actions_sync.dart b/packages/localsend_isolates/lib/src/isolate/parent/actions_sync.dart index ec04c458..5ace0651 100644 --- a/packages/localsend_isolates/lib/src/isolate/parent/actions_sync.dart +++ b/packages/localsend_isolates/lib/src/isolate/parent/actions_sync.dart @@ -131,6 +131,18 @@ class _PublishSyncStateAction extends ReduxAction respondFileUpload({ + /// (either a [path] or a [fileDescriptor]). + /// + /// The returned stream emits the progress (fraction of [fileSize]) while the + /// file is being received and closes once the file has been received + /// completely (or errors when saving failed). + Stream respondFileUpload({ required String sessionId, required String fileId, required String? path, required int? fileDescriptor, - }) async { - await _requireServer().respondFileUpload( + required int fileSize, + }) { + return _requireServer().respondFileUpload( sessionId: sessionId, fileId: fileId, path: path, fileDescriptor: fileDescriptor, + fileSize: BigInt.from(fileSize), ); } + /// Rejects a pending file upload, e.g. because no save target could be + /// prepared. The upload request fails with an error response and the file is + /// marked as failed; the session itself continues. + /// Does nothing if the upload was already answered via [respondFileUpload]. + Future rejectFileUpload({required String sessionId, required String fileId}) async { + await _requireServer().rejectFileUpload(sessionId: sessionId, fileId: fileId); + } + + /// Cancels the active upload session. Uploads that are already in progress + /// still run to completion, but new upload requests are rejected and a new + /// session can be created. No session-end event is emitted. + Future cancelSession({required String sessionId}) async { + await _requireServer().cancelSession(sessionId: sessionId); + } + /// Answers a pending web prepare-download request. /// [accept] grants the download; `false` declines it. Future respondPrepareDownload({required String sessionId, required bool accept}) async { @@ -89,7 +110,15 @@ class HttpServerService { ); } + /// Rejects a pending web file download, e.g. because no content source could + /// be resolved. The download request fails with an error response. + /// Does nothing if the download was already answered via [respondFileDownload]. + Future rejectFileDownload({required String sessionId, required String fileId}) async { + await _requireServer().rejectFileDownload(sessionId: sessionId, fileId: fileId); + } + /// Stops the server. The event stream returned by [start] will end. + /// Completes once the port is released and can be bound again. Future stop() async { final server = _server; _server = null; diff --git a/packages/localsend_isolates/lib/util/rust.dart b/packages/localsend_isolates/lib/util/rust.dart index 6c845d65..ff78eacb 100644 --- a/packages/localsend_isolates/lib/util/rust.dart +++ b/packages/localsend_isolates/lib/util/rust.dart @@ -4,6 +4,7 @@ import 'package:localsend_isolates/model/dto/file_dto.dart'; import 'package:localsend_isolates/model/dto/multicast_dto.dart'; import 'package:localsend_isolates/rust/api/http.dart' as rust_http; import 'package:localsend_isolates/rust/api/model.dart' as rust_model; +import 'package:localsend_isolates/rust/api/server.dart' as rust_server; import 'package:localsend_isolates/src/isolate/child/sync_provider.dart'; import 'package:mime/mime.dart'; @@ -108,6 +109,43 @@ extension HumanErrorMessageExt on Object { } } +extension RustFileDtoExt on rust_model.FileDto { + FileDto toDart() { + return FileDto( + id: id, + fileName: fileName, + size: size.toInt(), + fileType: decodeFromMime(fileType), + hash: sha256, + preview: preview, + metadata: metadata != null + ? FileMetadata( + lastModified: metadata!.modified != null ? DateTime.tryParse(metadata!.modified!) : null, + lastAccessed: metadata!.accessed != null ? DateTime.tryParse(metadata!.accessed!) : null, + ) + : null, + ); + } +} + +extension RegisterDtoV2Ext on rust_server.RegisterDtoV2 { + Device toDevice(String ip, DiscoveryMethod? method) { + return Device( + signalingId: null, + ip: ip, + version: version, + port: port, + https: protocol == rust_server.ProtocolTypeV2.https, + fingerprint: fingerprint, + alias: alias, + deviceModel: deviceModel, + deviceType: deviceType?.toDart() ?? DeviceType.desktop, + download: download, + discoveryMethods: method == null ? const {} : {method}, + ); + } +} + extension RegisterResponseDtoExt on rust_model.RegisterResponseDto { Device toDevice(String ip, int port, bool https, DiscoveryMethod method) { return Device( diff --git a/packages/localsend_isolates/rust/Cargo.lock b/packages/localsend_isolates/rust/Cargo.lock index e34f716c..a8da2bd8 100644 --- a/packages/localsend_isolates/rust/Cargo.lock +++ b/packages/localsend_isolates/rust/Cargo.lock @@ -2773,6 +2773,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] diff --git a/packages/localsend_isolates/rust/src/api/server.rs b/packages/localsend_isolates/rust/src/api/server.rs index ba2606bd..d5ebc90a 100644 --- a/packages/localsend_isolates/rust/src/api/server.rs +++ b/packages/localsend_isolates/rust/src/api/server.rs @@ -25,6 +25,8 @@ pub enum RsServerEvent { /// A sender requests to upload files via `POST /api/localsend/v2/prepare-upload`. PrepareUpload { + /// The session ID the upload session will have when the request is accepted. + session_id: String, ip: String, info: RegisterDtoV2, files: HashMap, @@ -43,6 +45,18 @@ pub enum RsServerEvent { reason: SessionEndReasonV2, }, + /// A prepare-upload request was aborted before a session was created, + /// e.g. the sender disconnected while the application was still deciding. + /// The [RsServerEvent::PrepareUpload] with the same session ID + /// no longer needs to be answered. + PrepareUploadAborted { session_id: String }, + + /// `POST /api/localsend/v2/cancel` was received for a session this server + /// does not manage: the remote device cancels a transfer this application + /// is currently *sending* to it. The application must verify that [ip] + /// matches the target of the send session before cancelling it. + CancelReceived { ip: String, session_id: String }, + /// A web client requests to download the shared files via `POST /api/localsend/v2/prepare-download`. /// /// Must be answered with [RsHttpServer::respond_prepare_download]. @@ -70,9 +84,10 @@ pub enum RsServerEvent { } pub struct RsHttpServer { + handle: localsend::http::server::ServerHandle, event_rx: Mutex>>, stop_tx: Mutex>>, - pending_decision: Mutex>>, + pending_decision: Mutex)>>, pending_uploads: Mutex>>, web_event_rx: Mutex>>, pending_download_decisions: Mutex>>, @@ -148,7 +163,7 @@ pub async fn start_server( None => (None, None), }; - localsend::http::server::start_with_port( + let handle = localsend::http::server::start_with_port( port, tls, ClientInfo { @@ -166,6 +181,7 @@ pub async fn start_server( .await?; Ok(RsHttpServer { + handle, event_rx: Mutex::new(Some(event_rx)), stop_tx: Mutex::new(Some(stop_tx)), pending_decision: Mutex::new(None), @@ -231,13 +247,15 @@ impl RsHttpServer { }); } ServerEventV2::PrepareUpload { + session_id, ip, info, files, decision_tx, } => { - *self.pending_decision.lock().await = Some(decision_tx); + *self.pending_decision.lock().await = Some((session_id.clone(), decision_tx)); let _ = sink.add(RsServerEvent::PrepareUpload { + session_id, ip: ip.to_string(), info, files, @@ -267,6 +285,24 @@ impl RsHttpServer { .retain(|(sid, _), _| sid != &session_id); let _ = sink.add(RsServerEvent::SessionEnd { session_id, reason }); } + ServerEventV2::PrepareUploadAborted { session_id } => { + // Drop the stale decision responder (the request already ended). + // A newer prepare-upload request may already hold the slot; + // only clear it if it still belongs to the aborted request. + { + let mut pending = self.pending_decision.lock().await; + if pending.as_ref().is_some_and(|(sid, _)| sid == &session_id) { + *pending = None; + } + } + let _ = sink.add(RsServerEvent::PrepareUploadAborted { session_id }); + } + ServerEventV2::CancelReceived { ip, session_id } => { + let _ = sink.add(RsServerEvent::CancelReceived { + ip: ip.to_string(), + session_id, + }); + } } } @@ -315,7 +351,7 @@ impl RsHttpServer { &self, accepted_file_ids: Option>, ) -> anyhow::Result<()> { - let Some(decision_tx) = self.pending_decision.lock().await.take() else { + let Some((_, decision_tx)) = self.pending_decision.lock().await.take() else { return Err(anyhow::anyhow!("No pending prepare-upload request")); }; @@ -334,12 +370,17 @@ impl RsHttpServer { /// Answers the pending [RsServerEvent::FileUpload] event with the target /// the file should be saved to (either a path or a file descriptor) /// and waits until the file has been received completely. + /// + /// The progress (fraction of [file_size]) is emitted on [sink] + /// while the file is being received. pub async fn respond_file_upload( &self, + sink: StreamSink, session_id: String, file_id: String, path: Option, file_descriptor: Option, + file_size: u64, ) -> anyhow::Result<()> { let Some(target_tx) = self .pending_uploads @@ -350,8 +391,31 @@ impl RsHttpServer { return Err(anyhow::anyhow!("No pending file upload for this file")); }; + let (progress_tx, mut progress_rx) = mpsc::channel::(16); + tokio::spawn(async move { + let mut last_emit = None::; + while let Some(written) = progress_rx.recv().await { + let now = std::time::Instant::now(); + let is_final = written >= file_size; + if !is_final { + if let Some(last) = last_emit { + if now.duration_since(last) < std::time::Duration::from_millis(20) { + continue; + } + } + } + last_emit = Some(now); + let progress = if file_size == 0 { + 1.0 + } else { + (written as f64 / file_size as f64).min(1.0) + }; + let _ = sink.add(progress); + } + }); + let (result_tx, result_rx) = oneshot::channel::>(); - let target = resolve_upload_target(path, file_descriptor, result_tx)?; + let target = resolve_upload_target(path, file_descriptor, result_tx, progress_tx)?; target_tx .send(target) @@ -364,6 +428,19 @@ impl RsHttpServer { } } + /// Rejects the pending [RsServerEvent::FileUpload] event, e.g. because + /// the application failed to prepare a save target for the file. + /// + /// The upload request fails with an error response and the file is marked + /// as failed. Does nothing if the upload was already answered. + pub async fn reject_file_upload(&self, session_id: String, file_id: String) { + // Dropping the responder fails the request waiting for the target. + self.pending_uploads + .lock() + .await + .remove(&(session_id, file_id)); + } + /// Answers the pending [RsServerEvent::WebPrepareDownload] event. /// /// Passing `true` accepts the download request, `false` declines it. @@ -417,10 +494,43 @@ impl RsHttpServer { Ok(()) } + /// Rejects the pending [RsServerEvent::WebFileDownload] event, e.g. because + /// the application failed to resolve a source for the file content. + /// + /// The download request fails with an error response. + /// Does nothing if the download was already answered. + pub async fn reject_file_download(&self, session_id: String, file_id: String) { + // Dropping the responder fails the request waiting for the content. + self.pending_downloads + .lock() + .await + .remove(&(session_id, file_id)); + } + + /// Cancels the active upload session, e.g. because the user aborted the + /// transfer on the receiving side. + /// + /// Uploads that are already in progress still run to completion, but new + /// upload requests are rejected and a new session can be created. + /// No [RsServerEvent::SessionEnd] is emitted: the application initiated + /// the cancellation itself. + pub async fn cancel_session(&self, session_id: String) { + self.handle.cancel_v2_session(&session_id).await; + + // Drop unanswered upload responders of this session so their requests + // fail instead of waiting for a target forever. + self.pending_uploads + .lock() + .await + .retain(|(sid, _), _| sid != &session_id); + } + /// Stops the server. + /// Returns after the listeners are closed, so the port can be bound again. pub async fn stop(&self) { if let Some(stop_tx) = self.stop_tx.lock().await.take() { let _ = stop_tx.send(()); + self.handle.wait_stopped().await; } } } @@ -438,11 +548,13 @@ fn resolve_upload_target( path: Option, file_descriptor: Option, result_tx: oneshot::Sender>, + progress_tx: mpsc::Sender, ) -> anyhow::Result { match (path, file_descriptor) { (Some(path), None) => Ok(FileUploadTarget::Path { path: path.into(), result_tx, + progress_tx: Some(progress_tx), }), (None, Some(file_descriptor)) => { #[cfg(target_os = "android")] @@ -450,11 +562,12 @@ fn resolve_upload_target( Ok(FileUploadTarget::Fd { fd: file_descriptor, result_tx, + progress_tx: Some(progress_tx), }) } #[cfg(not(target_os = "android"))] { - let _ = (file_descriptor, result_tx); + let _ = (file_descriptor, result_tx, progress_tx); Err(anyhow::anyhow!( "File descriptors are only supported on Android" )) diff --git a/packages/localsend_isolates/rust/src/frb_generated.rs b/packages/localsend_isolates/rust/src/frb_generated.rs index 355d6de1..1ed3e852 100644 --- a/packages/localsend_isolates/rust/src/frb_generated.rs +++ b/packages/localsend_isolates/rust/src/frb_generated.rs @@ -42,7 +42,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1029282510; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1916764705; // Section: executor @@ -711,6 +711,68 @@ fn wire__crate__api__http__RsHttpClient_upload_impl( }, ) } +fn wire__crate__api__server__RsHttpServer_cancel_session_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "RsHttpServer_cancel_session", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + let api_session_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, ()>( + (move || async move { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order( + vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + )], + ); + for i in decode_indices_ { + match i { + 0 => { + api_that_guard = + Some(api_that.lockable_decode_async_ref().await) + } + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = Result::<_, ()>::Ok({ + crate::api::server::RsHttpServer::cancel_session( + &*api_that_guard, + api_session_id, + ) + .await; + })?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__server__RsHttpServer_listen_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -773,6 +835,134 @@ fn wire__crate__api__server__RsHttpServer_listen_impl( }, ) } +fn wire__crate__api__server__RsHttpServer_reject_file_download_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "RsHttpServer_reject_file_download", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + let api_session_id = ::sse_decode(&mut deserializer); + let api_file_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, ()>( + (move || async move { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order( + vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + )], + ); + for i in decode_indices_ { + match i { + 0 => { + api_that_guard = + Some(api_that.lockable_decode_async_ref().await) + } + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = Result::<_, ()>::Ok({ + crate::api::server::RsHttpServer::reject_file_download( + &*api_that_guard, + api_session_id, + api_file_id, + ) + .await; + })?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__server__RsHttpServer_reject_file_upload_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "RsHttpServer_reject_file_upload", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + let api_session_id = ::sse_decode(&mut deserializer); + let api_file_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, ()>( + (move || async move { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order( + vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + )], + ); + for i in decode_indices_ { + match i { + 0 => { + api_that_guard = + Some(api_that.lockable_decode_async_ref().await) + } + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = Result::<_, ()>::Ok({ + crate::api::server::RsHttpServer::reject_file_upload( + &*api_that_guard, + api_session_id, + api_file_id, + ) + .await; + })?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__server__RsHttpServer_respond_file_download_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -864,10 +1054,15 @@ fn wire__crate__api__server__RsHttpServer_respond_file_upload_impl( let api_that = , >>::sse_decode(&mut deserializer); + let api_sink = + >::sse_decode( + &mut deserializer, + ); let api_session_id = ::sse_decode(&mut deserializer); let api_file_id = ::sse_decode(&mut deserializer); let api_path = >::sse_decode(&mut deserializer); let api_file_descriptor = >::sse_decode(&mut deserializer); + let api_file_size = ::sse_decode(&mut deserializer); deserializer.end(); move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( @@ -891,10 +1086,12 @@ fn wire__crate__api__server__RsHttpServer_respond_file_upload_impl( let api_that_guard = api_that_guard.unwrap(); let output_ok = crate::api::server::RsHttpServer::respond_file_upload( &*api_that_guard, + api_sink, api_session_id, api_file_id, api_path, api_file_descriptor, + api_file_size, ) .await?; Ok(output_ok) @@ -3504,6 +3701,7 @@ impl SseDecode for crate::api::server::RsServerEvent { }; } 1 => { + let mut var_sessionId = ::sse_decode(deserializer); let mut var_ip = ::sse_decode(deserializer); let mut var_info = ::sse_decode(deserializer); let mut var_files = @@ -3511,6 +3709,7 @@ impl SseDecode for crate::api::server::RsServerEvent { deserializer, ); return crate::api::server::RsServerEvent::PrepareUpload { + session_id: var_sessionId, ip: var_ip, info: var_info, files: var_files, @@ -3536,6 +3735,20 @@ impl SseDecode for crate::api::server::RsServerEvent { }; } 4 => { + let mut var_sessionId = ::sse_decode(deserializer); + return crate::api::server::RsServerEvent::PrepareUploadAborted { + session_id: var_sessionId, + }; + } + 5 => { + let mut var_ip = ::sse_decode(deserializer); + let mut var_sessionId = ::sse_decode(deserializer); + return crate::api::server::RsServerEvent::CancelReceived { + ip: var_ip, + session_id: var_sessionId, + }; + } + 6 => { let mut var_ip = ::sse_decode(deserializer); let mut var_sessionId = ::sse_decode(deserializer); let mut var_userAgent = >::sse_decode(deserializer); @@ -3545,7 +3758,7 @@ impl SseDecode for crate::api::server::RsServerEvent { user_agent: var_userAgent, }; } - 5 => { + 7 => { let mut var_sessionId = ::sse_decode(deserializer); let mut var_fileId = ::sse_decode(deserializer); let mut var_file = ::sse_decode(deserializer); @@ -3555,7 +3768,7 @@ impl SseDecode for crate::api::server::RsServerEvent { file: var_file, }; } - 6 => { + 8 => { let mut var_args = >::sse_decode(deserializer); return crate::api::server::RsServerEvent::Show { args: var_args }; } @@ -3838,131 +4051,149 @@ fn pde_ffi_dispatcher_primary_impl( ), 9 => wire__crate__api__http__RsHttpClient_register_impl(port, ptr, rust_vec_len, data_len), 10 => wire__crate__api__http__RsHttpClient_upload_impl(port, ptr, rust_vec_len, data_len), - 11 => wire__crate__api__server__RsHttpServer_listen_impl(port, ptr, rust_vec_len, data_len), - 12 => wire__crate__api__server__RsHttpServer_respond_file_download_impl( + 11 => wire__crate__api__server__RsHttpServer_cancel_session_impl( port, ptr, rust_vec_len, data_len, ), - 13 => wire__crate__api__server__RsHttpServer_respond_file_upload_impl( + 12 => wire__crate__api__server__RsHttpServer_listen_impl(port, ptr, rust_vec_len, data_len), + 13 => wire__crate__api__server__RsHttpServer_reject_file_download_impl( port, ptr, rust_vec_len, data_len, ), - 14 => wire__crate__api__server__RsHttpServer_respond_prepare_download_impl( + 14 => wire__crate__api__server__RsHttpServer_reject_file_upload_impl( port, ptr, rust_vec_len, data_len, ), - 15 => wire__crate__api__server__RsHttpServer_respond_prepare_upload_impl( + 15 => wire__crate__api__server__RsHttpServer_respond_file_download_impl( port, ptr, rust_vec_len, data_len, ), - 16 => wire__crate__api__server__RsHttpServer_stop_impl(port, ptr, rust_vec_len, data_len), - 17 => wire__crate__api__webrtc__RtcFileReceiver_get_file_id_impl( + 16 => wire__crate__api__server__RsHttpServer_respond_file_upload_impl( port, ptr, rust_vec_len, data_len, ), - 18 => wire__crate__api__webrtc__RtcFileReceiver_receive_impl( + 17 => wire__crate__api__server__RsHttpServer_respond_prepare_download_impl( port, ptr, rust_vec_len, data_len, ), - 19 => wire__crate__api__webrtc__RtcFileSender_send_impl(port, ptr, rust_vec_len, data_len), - 20 => wire__crate__api__webrtc__RtcReceiveController_decline_impl( + 18 => wire__crate__api__server__RsHttpServer_respond_prepare_upload_impl( port, ptr, rust_vec_len, data_len, ), - 21 => wire__crate__api__webrtc__RtcReceiveController_listen_error_impl( + 19 => wire__crate__api__server__RsHttpServer_stop_impl(port, ptr, rust_vec_len, data_len), + 20 => wire__crate__api__webrtc__RtcFileReceiver_get_file_id_impl( port, ptr, rust_vec_len, data_len, ), - 22 => wire__crate__api__webrtc__RtcReceiveController_listen_files_impl( + 21 => wire__crate__api__webrtc__RtcFileReceiver_receive_impl( port, ptr, rust_vec_len, data_len, ), - 23 => wire__crate__api__webrtc__RtcReceiveController_listen_receiving_impl( + 22 => wire__crate__api__webrtc__RtcFileSender_send_impl(port, ptr, rust_vec_len, data_len), + 23 => wire__crate__api__webrtc__RtcReceiveController_decline_impl( port, ptr, rust_vec_len, data_len, ), - 24 => wire__crate__api__webrtc__RtcReceiveController_listen_status_impl( + 24 => wire__crate__api__webrtc__RtcReceiveController_listen_error_impl( port, ptr, rust_vec_len, data_len, ), - 25 => wire__crate__api__webrtc__RtcReceiveController_send_file_status_impl( + 25 => wire__crate__api__webrtc__RtcReceiveController_listen_files_impl( port, ptr, rust_vec_len, data_len, ), - 26 => wire__crate__api__webrtc__RtcReceiveController_send_pin_impl( + 26 => wire__crate__api__webrtc__RtcReceiveController_listen_receiving_impl( port, ptr, rust_vec_len, data_len, ), - 27 => wire__crate__api__webrtc__RtcReceiveController_send_selection_impl( + 27 => wire__crate__api__webrtc__RtcReceiveController_listen_status_impl( port, ptr, rust_vec_len, data_len, ), - 28 => wire__crate__api__webrtc__RtcSendController_listen_error_impl( + 28 => wire__crate__api__webrtc__RtcReceiveController_send_file_status_impl( port, ptr, rust_vec_len, data_len, ), - 29 => wire__crate__api__webrtc__RtcSendController_listen_selected_files_impl( + 29 => wire__crate__api__webrtc__RtcReceiveController_send_pin_impl( port, ptr, rust_vec_len, data_len, ), - 30 => wire__crate__api__webrtc__RtcSendController_listen_status_impl( + 30 => wire__crate__api__webrtc__RtcReceiveController_send_selection_impl( port, ptr, rust_vec_len, data_len, ), - 31 => wire__crate__api__webrtc__RtcSendController_send_file_impl( + 31 => wire__crate__api__webrtc__RtcSendController_listen_error_impl( port, ptr, rust_vec_len, data_len, ), - 32 => wire__crate__api__webrtc__RtcSendController_send_pin_impl( + 32 => wire__crate__api__webrtc__RtcSendController_listen_selected_files_impl( port, ptr, rust_vec_len, data_len, ), - 33 => wire__crate__api__webrtc__connect_impl(port, ptr, rust_vec_len, data_len), - 36 => wire__crate__api__stream__create_stream_impl(port, ptr, rust_vec_len, data_len), - 37 => { + 33 => wire__crate__api__webrtc__RtcSendController_listen_status_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 34 => wire__crate__api__webrtc__RtcSendController_send_file_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 35 => wire__crate__api__webrtc__RtcSendController_send_pin_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 36 => wire__crate__api__webrtc__connect_impl(port, ptr, rust_vec_len, data_len), + 39 => wire__crate__api__stream__create_stream_impl(port, ptr, rust_vec_len, data_len), + 40 => { wire__crate__api__logging__enable_debug_logging_impl(port, ptr, rust_vec_len, data_len) } - 38 => wire__crate__api__crypto__generate_key_pair_impl(port, ptr, rust_vec_len, data_len), - 39 => wire__crate__api__server__start_server_impl(port, ptr, rust_vec_len, data_len), - 40 => wire__crate__api__crypto__verify_cert_impl(port, ptr, rust_vec_len, data_len), + 41 => wire__crate__api__crypto__generate_key_pair_impl(port, ptr, rust_vec_len, data_len), + 42 => wire__crate__api__server__start_server_impl(port, ptr, rust_vec_len, data_len), + 43 => wire__crate__api__crypto__verify_cert_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -3977,8 +4208,8 @@ fn pde_ffi_dispatcher_sync_impl( match func_id { 2 => wire__crate__api__stream__Dart2RustStreamSink_close_impl(ptr, rust_vec_len, data_len), 6 => wire__crate__api__http__RsCancellationToken_cancel_impl(ptr, rust_vec_len, data_len), - 34 => wire__crate__api__http__create_cancellation_token_impl(ptr, rust_vec_len, data_len), - 35 => wire__crate__api__http__create_client_impl(ptr, rust_vec_len, data_len), + 37 => wire__crate__api__http__create_cancellation_token_impl(ptr, rust_vec_len, data_len), + 38 => wire__crate__api__http__create_client_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -4632,8 +4863,14 @@ impl flutter_rust_bridge::IntoDart for crate::api::server::RsServerEvent { info.into_into_dart().into_dart(), ] .into_dart(), - crate::api::server::RsServerEvent::PrepareUpload { ip, info, files } => [ + crate::api::server::RsServerEvent::PrepareUpload { + session_id, + ip, + info, + files, + } => [ 1.into_dart(), + session_id.into_into_dart().into_dart(), ip.into_into_dart().into_dart(), info.into_into_dart().into_dart(), files.into_into_dart().into_dart(), @@ -4656,12 +4893,21 @@ impl flutter_rust_bridge::IntoDart for crate::api::server::RsServerEvent { reason.into_into_dart().into_dart(), ] .into_dart(), + crate::api::server::RsServerEvent::PrepareUploadAborted { session_id } => { + [4.into_dart(), session_id.into_into_dart().into_dart()].into_dart() + } + crate::api::server::RsServerEvent::CancelReceived { ip, session_id } => [ + 5.into_dart(), + ip.into_into_dart().into_dart(), + session_id.into_into_dart().into_dart(), + ] + .into_dart(), crate::api::server::RsServerEvent::WebPrepareDownload { ip, session_id, user_agent, } => [ - 4.into_dart(), + 6.into_dart(), ip.into_into_dart().into_dart(), session_id.into_into_dart().into_dart(), user_agent.into_into_dart().into_dart(), @@ -4672,14 +4918,14 @@ impl flutter_rust_bridge::IntoDart for crate::api::server::RsServerEvent { file_id, file, } => [ - 5.into_dart(), + 7.into_dart(), session_id.into_into_dart().into_dart(), file_id.into_into_dart().into_dart(), file.into_into_dart().into_dart(), ] .into_dart(), crate::api::server::RsServerEvent::Show { args } => { - [6.into_dart(), args.into_into_dart().into_dart()].into_dart() + [8.into_dart(), args.into_into_dart().into_dart()].into_dart() } _ => { unimplemented!(""); @@ -5740,8 +5986,14 @@ impl SseEncode for crate::api::server::RsServerEvent { ::sse_encode(ip, serializer); ::sse_encode(info, serializer); } - crate::api::server::RsServerEvent::PrepareUpload { ip, info, files } => { + crate::api::server::RsServerEvent::PrepareUpload { + session_id, + ip, + info, + files, + } => { ::sse_encode(1, serializer); + ::sse_encode(session_id, serializer); ::sse_encode(ip, serializer); ::sse_encode(info, serializer); >::sse_encode( @@ -5763,12 +6015,21 @@ impl SseEncode for crate::api::server::RsServerEvent { ::sse_encode(session_id, serializer); ::sse_encode(reason, serializer); } + crate::api::server::RsServerEvent::PrepareUploadAborted { session_id } => { + ::sse_encode(4, serializer); + ::sse_encode(session_id, serializer); + } + crate::api::server::RsServerEvent::CancelReceived { ip, session_id } => { + ::sse_encode(5, serializer); + ::sse_encode(ip, serializer); + ::sse_encode(session_id, serializer); + } crate::api::server::RsServerEvent::WebPrepareDownload { ip, session_id, user_agent, } => { - ::sse_encode(4, serializer); + ::sse_encode(6, serializer); ::sse_encode(ip, serializer); ::sse_encode(session_id, serializer); >::sse_encode(user_agent, serializer); @@ -5778,13 +6039,13 @@ impl SseEncode for crate::api::server::RsServerEvent { file_id, file, } => { - ::sse_encode(5, serializer); + ::sse_encode(7, serializer); ::sse_encode(session_id, serializer); ::sse_encode(file_id, serializer); ::sse_encode(file, serializer); } crate::api::server::RsServerEvent::Show { args } => { - ::sse_encode(6, serializer); + ::sse_encode(8, serializer); >::sse_encode(args, serializer); } _ => {