feat: add web receive
CI / format (push) Has been cancelled
CI / test (push) Has been cancelled
CI / packaging (push) Has been cancelled

This commit is contained in:
Tien Do Nam
2026-08-03 04:33:21 +02:00
parent 62d5ca9687
commit 73ff2fe6e2
37 changed files with 1247 additions and 217 deletions
+1 -1
View File
@@ -108,7 +108,7 @@ Save targets are decided in Dart (`prepareFileSaveTarget`) and written by Rust:
Server event `ip`s are `PeerIp` (IP + IPv6 scope): a link-local peer renders as `fe80::1%3`, which the HTTP client accepts back as a host, so event ips stay dialable. Server event `ip`s are `PeerIp` (IP + IPv6 scope): a link-local peer renders as `fe80::1%3`, which the HTTP client accepts back as a host, so event ips stay dialable.
TLS uses per-device on-the-fly certificates with **mandatory client certificates**; the peer identity is the uppercase-hex SHA-256 of the client cert DER, and `Register` is simply not emitted when a payload's claimed fingerprint disagrees with the cert. Prefer `event.certFingerprint ?? event.info.fingerprint` — the payload fallback only exists for encryption-off mode. TLS uses per-device on-the-fly certificates with **mandatory client certificates** (optional while the web pages are served, so browsers can connect); the peer identity is the uppercase-hex SHA-256 of the client cert DER, and `Register` is simply not emitted when a payload's claimed fingerprint disagrees with the cert. Prefer `event.certFingerprint ?? event.info.fingerprint` — the payload fallback only exists for encryption-off mode.
Both the receive pin and the web-send pin are fixed at server start, so changing either restarts the server. Both the receive pin and the web-send pin are fixed at server start, so changing either restarts the server.
+3
View File
@@ -268,6 +268,9 @@
"encryptionHint": "LocalSend uses a self-signed certificate. You need to accept it in your browser.", "encryptionHint": "LocalSend uses a self-signed certificate. You need to accept it in your browser.",
"pendingRequests": "Pending requests: {n}" "pendingRequests": "Pending requests: {n}"
}, },
"webReceivePage": {
"title": "Receive via link"
},
"aboutPage": { "aboutPage": {
"title": "About LocalSend", "title": "About LocalSend",
"description": [ "description": [
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 55 /// Locales: 55
/// Strings: 18450 (335 per locale) /// Strings: 18451 (335 per locale)
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import
+13
View File
@@ -63,6 +63,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
late final Translations$sendPage$en sendPage = Translations$sendPage$en.internal(_root); late final Translations$sendPage$en sendPage = Translations$sendPage$en.internal(_root);
late final Translations$progressPage$en progressPage = Translations$progressPage$en.internal(_root); late final Translations$progressPage$en progressPage = Translations$progressPage$en.internal(_root);
late final Translations$webSharePage$en webSharePage = Translations$webSharePage$en.internal(_root); late final Translations$webSharePage$en webSharePage = Translations$webSharePage$en.internal(_root);
late final Translations$webReceivePage$en webReceivePage = Translations$webReceivePage$en.internal(_root);
late final Translations$aboutPage$en aboutPage = Translations$aboutPage$en.internal(_root); late final Translations$aboutPage$en aboutPage = Translations$aboutPage$en.internal(_root);
late final Translations$donationPage$en donationPage = Translations$donationPage$en.internal(_root); late final Translations$donationPage$en donationPage = Translations$donationPage$en.internal(_root);
late final Translations$changelogPage$en changelogPage = Translations$changelogPage$en.internal(_root); late final Translations$changelogPage$en changelogPage = Translations$changelogPage$en.internal(_root);
@@ -530,6 +531,18 @@ class Translations$webSharePage$en {
String pendingRequests({required Object n}) => 'Pending requests: ${n}'; String pendingRequests({required Object n}) => 'Pending requests: ${n}';
} }
// Path: webReceivePage
class Translations$webReceivePage$en {
Translations$webReceivePage$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Receive via link'
String get title => 'Receive via link';
}
// Path: aboutPage // Path: aboutPage
class Translations$aboutPage$en { class Translations$aboutPage$en {
Translations$aboutPage$en.internal(this._root); Translations$aboutPage$en.internal(this._root);
+5 -1
View File
@@ -12,16 +12,20 @@ class ServerState with ServerStateMappable {
final ReceiveSessionState? session; final ReceiveSessionState? session;
final WebSendState? webSendState; final WebSendState? webSendState;
/// Whether the upload page is served so web browsers can upload files.
final bool webUpload;
const ServerState({ const ServerState({
required this.alias, required this.alias,
required this.port, required this.port,
required this.https, required this.https,
required this.session, required this.session,
required this.webSendState, required this.webSendState,
required this.webUpload,
}); });
@override @override
String toString() { String toString() {
return 'ServerState(alias: $alias, port: $port, https: $https, session: $session, webSendState: $webSendState)'; return 'ServerState(alias: $alias, port: $port, https: $https, session: $session, webSendState: $webSendState, webUpload: $webUpload)';
} }
} }
@@ -40,6 +40,11 @@ class ServerStateMapper extends ClassMapperBase<ServerState> {
'webSendState', 'webSendState',
_$webSendState, _$webSendState,
); );
static bool _$webUpload(ServerState v) => v.webUpload;
static const Field<ServerState, bool> _f$webUpload = Field(
'webUpload',
_$webUpload,
);
@override @override
final MappableFields<ServerState> fields = const { final MappableFields<ServerState> fields = const {
@@ -48,6 +53,7 @@ class ServerStateMapper extends ClassMapperBase<ServerState> {
#https: _f$https, #https: _f$https,
#session: _f$session, #session: _f$session,
#webSendState: _f$webSendState, #webSendState: _f$webSendState,
#webUpload: _f$webUpload,
}; };
static ServerState _instantiate(DecodingData data) { static ServerState _instantiate(DecodingData data) {
@@ -57,6 +63,7 @@ class ServerStateMapper extends ClassMapperBase<ServerState> {
https: data.dec(_f$https), https: data.dec(_f$https),
session: data.dec(_f$session), session: data.dec(_f$session),
webSendState: data.dec(_f$webSendState), webSendState: data.dec(_f$webSendState),
webUpload: data.dec(_f$webUpload),
); );
} }
@@ -129,6 +136,7 @@ abstract class ServerStateCopyWith<$R, $In extends ServerState, $Out>
bool? https, bool? https,
ReceiveSessionState? session, ReceiveSessionState? session,
WebSendState? webSendState, WebSendState? webSendState,
bool? webUpload,
}); });
ServerStateCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); ServerStateCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t);
} }
@@ -154,6 +162,7 @@ class _ServerStateCopyWithImpl<$R, $Out>
bool? https, bool? https,
Object? session = $none, Object? session = $none,
Object? webSendState = $none, Object? webSendState = $none,
bool? webUpload,
}) => $apply( }) => $apply(
FieldCopyWithData({ FieldCopyWithData({
if (alias != null) #alias: alias, if (alias != null) #alias: alias,
@@ -161,6 +170,7 @@ class _ServerStateCopyWithImpl<$R, $Out>
if (https != null) #https: https, if (https != null) #https: https,
if (session != $none) #session: session, if (session != $none) #session: session,
if (webSendState != $none) #webSendState: webSendState, if (webSendState != $none) #webSendState: webSendState,
if (webUpload != null) #webUpload: webUpload,
}), }),
); );
@override @override
@@ -170,6 +180,7 @@ class _ServerStateCopyWithImpl<$R, $Out>
https: data.get(#https, or: $value.https), https: data.get(#https, or: $value.https),
session: data.get(#session, or: $value.session), session: data.get(#session, or: $value.session),
webSendState: data.get(#webSendState, or: $value.webSendState), webSendState: data.get(#webSendState, or: $value.webSendState),
webUpload: data.get(#webUpload, or: $value.webUpload),
); );
@override @override
+2
View File
@@ -33,6 +33,7 @@ class SettingsState with SettingsStateMappable {
final DeviceType? deviceType; final DeviceType? deviceType;
final String? deviceModel; final String? deviceModel;
final bool shareViaLinkAutoAccept; final bool shareViaLinkAutoAccept;
final bool receiveViaLinkAutoAccept;
final int discoveryTimeout; final int discoveryTimeout;
final bool advancedSettings; final bool advancedSettings;
@@ -61,6 +62,7 @@ class SettingsState with SettingsStateMappable {
required this.deviceType, required this.deviceType,
required this.deviceModel, required this.deviceModel,
required this.shareViaLinkAutoAccept, required this.shareViaLinkAutoAccept,
required this.receiveViaLinkAutoAccept,
required this.discoveryTimeout, required this.discoveryTimeout,
required this.advancedSettings, required this.advancedSettings,
}); });
@@ -138,6 +138,12 @@ class SettingsStateMapper extends ClassMapperBase<SettingsState> {
'shareViaLinkAutoAccept', 'shareViaLinkAutoAccept',
_$shareViaLinkAutoAccept, _$shareViaLinkAutoAccept,
); );
static bool _$receiveViaLinkAutoAccept(SettingsState v) =>
v.receiveViaLinkAutoAccept;
static const Field<SettingsState, bool> _f$receiveViaLinkAutoAccept = Field(
'receiveViaLinkAutoAccept',
_$receiveViaLinkAutoAccept,
);
static int _$discoveryTimeout(SettingsState v) => v.discoveryTimeout; static int _$discoveryTimeout(SettingsState v) => v.discoveryTimeout;
static const Field<SettingsState, int> _f$discoveryTimeout = Field( static const Field<SettingsState, int> _f$discoveryTimeout = Field(
'discoveryTimeout', 'discoveryTimeout',
@@ -175,6 +181,7 @@ class SettingsStateMapper extends ClassMapperBase<SettingsState> {
#deviceType: _f$deviceType, #deviceType: _f$deviceType,
#deviceModel: _f$deviceModel, #deviceModel: _f$deviceModel,
#shareViaLinkAutoAccept: _f$shareViaLinkAutoAccept, #shareViaLinkAutoAccept: _f$shareViaLinkAutoAccept,
#receiveViaLinkAutoAccept: _f$receiveViaLinkAutoAccept,
#discoveryTimeout: _f$discoveryTimeout, #discoveryTimeout: _f$discoveryTimeout,
#advancedSettings: _f$advancedSettings, #advancedSettings: _f$advancedSettings,
}; };
@@ -205,6 +212,7 @@ class SettingsStateMapper extends ClassMapperBase<SettingsState> {
deviceType: data.dec(_f$deviceType), deviceType: data.dec(_f$deviceType),
deviceModel: data.dec(_f$deviceModel), deviceModel: data.dec(_f$deviceModel),
shareViaLinkAutoAccept: data.dec(_f$shareViaLinkAutoAccept), shareViaLinkAutoAccept: data.dec(_f$shareViaLinkAutoAccept),
receiveViaLinkAutoAccept: data.dec(_f$receiveViaLinkAutoAccept),
discoveryTimeout: data.dec(_f$discoveryTimeout), discoveryTimeout: data.dec(_f$discoveryTimeout),
advancedSettings: data.dec(_f$advancedSettings), advancedSettings: data.dec(_f$advancedSettings),
); );
@@ -301,6 +309,7 @@ abstract class SettingsStateCopyWith<$R, $In extends SettingsState, $Out>
DeviceType? deviceType, DeviceType? deviceType,
String? deviceModel, String? deviceModel,
bool? shareViaLinkAutoAccept, bool? shareViaLinkAutoAccept,
bool? receiveViaLinkAutoAccept,
int? discoveryTimeout, int? discoveryTimeout,
bool? advancedSettings, bool? advancedSettings,
}); });
@@ -359,6 +368,7 @@ class _SettingsStateCopyWithImpl<$R, $Out>
Object? deviceType = $none, Object? deviceType = $none,
Object? deviceModel = $none, Object? deviceModel = $none,
bool? shareViaLinkAutoAccept, bool? shareViaLinkAutoAccept,
bool? receiveViaLinkAutoAccept,
int? discoveryTimeout, int? discoveryTimeout,
bool? advancedSettings, bool? advancedSettings,
}) => $apply( }) => $apply(
@@ -390,6 +400,8 @@ class _SettingsStateCopyWithImpl<$R, $Out>
if (deviceModel != $none) #deviceModel: deviceModel, if (deviceModel != $none) #deviceModel: deviceModel,
if (shareViaLinkAutoAccept != null) if (shareViaLinkAutoAccept != null)
#shareViaLinkAutoAccept: shareViaLinkAutoAccept, #shareViaLinkAutoAccept: shareViaLinkAutoAccept,
if (receiveViaLinkAutoAccept != null)
#receiveViaLinkAutoAccept: receiveViaLinkAutoAccept,
if (discoveryTimeout != null) #discoveryTimeout: discoveryTimeout, if (discoveryTimeout != null) #discoveryTimeout: discoveryTimeout,
if (advancedSettings != null) #advancedSettings: advancedSettings, if (advancedSettings != null) #advancedSettings: advancedSettings,
}), }),
@@ -429,6 +441,10 @@ class _SettingsStateCopyWithImpl<$R, $Out>
#shareViaLinkAutoAccept, #shareViaLinkAutoAccept,
or: $value.shareViaLinkAutoAccept, or: $value.shareViaLinkAutoAccept,
), ),
receiveViaLinkAutoAccept: data.get(
#receiveViaLinkAutoAccept,
or: $value.receiveViaLinkAutoAccept,
),
discoveryTimeout: data.get(#discoveryTimeout, or: $value.discoveryTimeout), discoveryTimeout: data.get(#discoveryTimeout, or: $value.discoveryTimeout),
advancedSettings: data.get(#advancedSettings, or: $value.advancedSettings), advancedSettings: data.get(#advancedSettings, or: $value.advancedSettings),
); );
+7 -2
View File
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:localsend_app/config/theme.dart'; import 'package:localsend_app/config/theme.dart';
import 'package:localsend_app/gen/strings.g.dart'; import 'package:localsend_app/gen/strings.g.dart';
import 'package:localsend_app/model/state/server/receive_session_state.dart'; import 'package:localsend_app/model/state/server/receive_session_state.dart';
import 'package:localsend_app/pages/web_receive_page.dart';
import 'package:localsend_app/provider/network/send_provider.dart'; import 'package:localsend_app/provider/network/send_provider.dart';
import 'package:localsend_app/provider/network/server/server_provider.dart'; import 'package:localsend_app/provider/network/server/server_provider.dart';
import 'package:localsend_app/provider/progress_provider.dart'; import 'package:localsend_app/provider/progress_provider.dart';
@@ -25,6 +26,7 @@ import 'package:localsend_isolates/model/file_status.dart';
import 'package:localsend_isolates/model/session_status.dart'; import 'package:localsend_isolates/model/session_status.dart';
import 'package:localsend_isolates/util/file_size_helper.dart'; import 'package:localsend_isolates/util/file_size_helper.dart';
import 'package:localsend_isolates/util/file_speed_helper.dart'; import 'package:localsend_isolates/util/file_speed_helper.dart';
import 'package:refena_flutter/addons.dart';
import 'package:refena_flutter/refena_flutter.dart'; import 'package:refena_flutter/refena_flutter.dart';
import 'package:routerino/routerino.dart'; import 'package:routerino/routerino.dart';
import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:wakelock_plus/wakelock_plus.dart';
@@ -140,8 +142,11 @@ class _ProgressPageState extends State<ProgressPage> with Refena {
final result = status == null || keepSession || await _askCancelConfirmation(status); final result = status == null || keepSession || await _askCancelConfirmation(status);
if (result && mounted) { if (result && mounted) {
// ignore: unawaited_futures if (ref.read(serverProvider)?.webUpload == true) {
context.popUntilRoot(); context.global.dispatch(NavigateAction.popUntil<WebReceivePage>());
} else {
context.global.dispatch(NavigateAction.popUntilRoot());
}
} }
} }
+7 -52
View File
@@ -4,6 +4,7 @@ import 'package:localsend_app/pages/home_page.dart';
import 'package:localsend_app/pages/home_page_controller.dart'; import 'package:localsend_app/pages/home_page_controller.dart';
import 'package:localsend_app/pages/receive_history_page.dart'; import 'package:localsend_app/pages/receive_history_page.dart';
import 'package:localsend_app/pages/tabs/receive_tab_vm.dart'; import 'package:localsend_app/pages/tabs/receive_tab_vm.dart';
import 'package:localsend_app/pages/web_receive_page.dart';
import 'package:localsend_app/provider/animation_provider.dart'; import 'package:localsend_app/provider/animation_provider.dart';
import 'package:localsend_app/util/ip_helper.dart'; import 'package:localsend_app/util/ip_helper.dart';
import 'package:localsend_app/widget/animations/initial_fade_transition.dart'; import 'package:localsend_app/widget/animations/initial_fade_transition.dart';
@@ -12,15 +13,10 @@ import 'package:localsend_app/widget/custom_icon_button.dart';
import 'package:localsend_app/widget/local_send_logo.dart'; import 'package:localsend_app/widget/local_send_logo.dart';
import 'package:localsend_app/widget/responsive_list_view.dart'; import 'package:localsend_app/widget/responsive_list_view.dart';
import 'package:localsend_app/widget/rotating_widget.dart'; import 'package:localsend_app/widget/rotating_widget.dart';
import 'package:refena_flutter/addons.dart';
import 'package:refena_flutter/refena_flutter.dart'; import 'package:refena_flutter/refena_flutter.dart';
import 'package:routerino/routerino.dart'; import 'package:routerino/routerino.dart';
enum _QuickSaveMode {
off,
favorites,
on,
}
class ReceiveTab extends StatelessWidget { class ReceiveTab extends StatelessWidget {
const ReceiveTab(); const ReceiveTab();
@@ -76,53 +72,12 @@ class ReceiveTab extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.only(top: 10), padding: const EdgeInsets.only(top: 10),
child: Center( child: Center(
child: Column( child: OutlinedButton.icon(
children: [ onPressed: () async {
Text(t.general.quickSave), await context.global.dispatchAsync(NavigateAction.push(WebReceivePage()));
const SizedBox(height: 10),
SegmentedButton<_QuickSaveMode>(
multiSelectionEnabled: false,
emptySelectionAllowed: false,
showSelectedIcon: false,
onSelectionChanged: (selection) async {
if (selection.contains(_QuickSaveMode.off)) {
await vm.onSetQuickSave(context, false);
if (context.mounted) {
await vm.onSetQuickSaveFromFavorites(context, false);
}
} else if (selection.contains(_QuickSaveMode.favorites)) {
await vm.onSetQuickSave(context, false);
if (context.mounted) {
await vm.onSetQuickSaveFromFavorites(context, true);
}
} else if (selection.contains(_QuickSaveMode.on)) {
await vm.onSetQuickSaveFromFavorites(context, false);
if (context.mounted) {
await vm.onSetQuickSave(context, true);
}
}
}, },
selected: { icon: Icon(Icons.language),
if (!vm.quickSaveSettings && !vm.quickSaveFromFavoritesSettings) _QuickSaveMode.off, label: Text(t.$wip.receiveTab.link('Receive via link')),
if (vm.quickSaveFromFavoritesSettings) _QuickSaveMode.favorites,
if (vm.quickSaveSettings) _QuickSaveMode.on,
},
segments: [
ButtonSegment(
value: _QuickSaveMode.off,
label: Text(t.receiveTab.quickSave.off),
),
ButtonSegment(
value: _QuickSaveMode.favorites,
label: Text(t.receiveTab.quickSave.favorites),
),
ButtonSegment(
value: _QuickSaveMode.on,
label: Text(t.receiveTab.quickSave.on),
),
],
),
],
), ),
), ),
), ),
+252
View File
@@ -0,0 +1,252 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:localsend_app/config/theme.dart';
import 'package:localsend_app/gen/strings.g.dart';
import 'package:localsend_app/provider/local_ip_provider.dart';
import 'package:localsend_app/provider/network/server/server_provider.dart';
import 'package:localsend_app/provider/settings_provider.dart';
import 'package:localsend_app/util/native/platform_check.dart';
import 'package:localsend_app/util/ui/snackbar.dart';
import 'package:localsend_app/widget/dialogs/qr_dialog.dart';
import 'package:localsend_app/widget/dialogs/zoom_dialog.dart';
import 'package:localsend_app/widget/responsive_list_view.dart';
import 'package:localsend_isolates/util/sleep.dart';
import 'package:refena_flutter/refena_flutter.dart';
import 'package:routerino/routerino.dart';
enum _ServerState { initializing, running, error, stopping }
/// Lets web browsers upload files to this device.
/// Incoming requests are not listed here because they open the receive page
/// like any other incoming request.
class WebReceivePage extends StatefulWidget {
const WebReceivePage();
@override
State<WebReceivePage> createState() => _WebReceivePageState();
}
class _WebReceivePageState extends State<WebReceivePage> with Refena {
_ServerState _stateEnum = _ServerState.initializing;
bool _encrypted = false;
String? _initializedError;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_init(encrypted: false);
});
}
void _init({required bool encrypted}) async {
final settings = ref.read(settingsProvider);
setState(() {
_stateEnum = _ServerState.initializing;
_encrypted = encrypted;
_initializedError = null;
});
await sleepAsync(500);
try {
await ref
.notifier(serverProvider)
.restartServer(
alias: settings.alias,
port: settings.port,
https: _encrypted,
webUpload: true,
);
setState(() {
_stateEnum = _ServerState.running;
});
} catch (e) {
if (context.mounted) {
setState(() {
_stateEnum = _ServerState.error;
_initializedError = e.toString();
});
}
}
}
/// Web receive uses unencrypted http by default, so we need to revert to the previous state.
Future<void> _revertServerState() async {
await ref.notifier(serverProvider).restartServerFromSettings();
}
@override
Widget build(BuildContext context) {
return PopScope(
onPopInvokedWithResult: (_, _) async {
if (_stateEnum != _ServerState.running) {
return;
}
setState(() {
_stateEnum = _ServerState.stopping;
});
await sleepAsync(250);
await _revertServerState();
await sleepAsync(250);
if (context.mounted) {
context.pop();
}
},
canPop: false,
child: Scaffold(
appBar: AppBar(
title: Text(t.webReceivePage.title),
),
body: Builder(
builder: (context) {
if (_stateEnum != _ServerState.running) {
return Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (_stateEnum == _ServerState.initializing || _stateEnum == _ServerState.stopping) ...[
const CircularProgressIndicator(),
const SizedBox(height: 20),
Center(
child: Text(
_stateEnum == _ServerState.initializing ? t.webSharePage.loading : t.webSharePage.stopping,
style: Theme.of(context).textTheme.titleLarge,
),
),
] else if (_initializedError != null) ...[
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 10),
Center(
child: Text(t.webSharePage.error, style: Theme.of(context).textTheme.titleLarge),
),
const SizedBox(height: 10),
Center(
child: SelectableText(_initializedError!, style: Theme.of(context).textTheme.bodyMedium),
),
],
],
);
}
final serverState = context.watch(serverProvider);
if (serverState == null) {
// the server is restarting
return const Center(child: CircularProgressIndicator());
}
final networkState = context.watch(localIpProvider);
final settings = context.watch(settingsProvider);
return ResponsiveListView(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 20),
children: [
Text(t.webSharePage.openLink(n: networkState.localIps.length), style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 10),
Card(
color: Theme.of(context).colorScheme.secondaryContainer,
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...networkState.localIps.map((ip) {
final url = '${_encrypted ? 'https' : 'http'}://$ip:${serverState.port}';
return Padding(
padding: const EdgeInsets.all(5),
child: Row(
children: [
SelectableText(
url,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(width: 5),
InkWell(
onTap: () async {
await Clipboard.setData(ClipboardData(text: url));
if (context.mounted && checkPlatformIsDesktop()) {
context.showSnackBar(t.general.copiedToClipboard);
}
},
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Icon(Icons.content_copy, size: 16),
),
),
InkWell(
onTap: () async {
await showDialog(
context: context,
builder: (_) => QrDialog(
data: url,
label: url,
),
);
},
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Icon(Icons.qr_code, size: 16),
),
),
InkWell(
onTap: () async {
await showDialog(
context: context,
builder: (_) => ZoomDialog(
label: url,
),
);
},
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Icon(Icons.tv, size: 16),
),
),
],
),
);
}),
],
),
),
),
const SizedBox(height: 20),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(t.webSharePage.encryption, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(width: 10),
Checkbox(
value: _encrypted,
onChanged: (value) {
_init(encrypted: value == true);
},
),
],
),
if (_encrypted)
Text(
t.webSharePage.encryptionHint,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: Theme.of(context).colorScheme.warning),
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(t.webSharePage.autoAccept, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(width: 10),
Checkbox(
value: settings.receiveViaLinkAutoAccept,
onChanged: (value) async {
await ref.notifier(settingsProvider).setReceiveViaLinkAutoAccept(value == true);
},
),
],
),
],
);
},
),
),
);
}
}
@@ -128,6 +128,10 @@ class ReceiveController {
quickSave = true; quickSave = true;
} }
} }
if (server.getState().webUpload && settings.receiveViaLinkAutoAccept && server.getState().session?.message == null) {
// The upload page (receive via link) is being served and requests should be accepted automatically.
quickSave = true;
}
if (quickSave) { if (quickSave) {
// accept all files // accept all files
@@ -12,7 +12,7 @@ import 'package:localsend_app/util/alias_generator.dart';
import 'package:localsend_isolates/constants.dart'; import 'package:localsend_isolates/constants.dart';
import 'package:localsend_isolates/isolate.dart'; import 'package:localsend_isolates/isolate.dart';
import 'package:localsend_isolates/model/dto/multicast_dto.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/rust/api/server.dart' show WebI18n, WebParams, WebSendParams;
import 'package:localsend_isolates/util/rust.dart'; import 'package:localsend_isolates/util/rust.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:refena_flutter/refena_flutter.dart'; import 'package:refena_flutter/refena_flutter.dart';
@@ -92,11 +92,13 @@ class ServerService extends Notifier<ServerState?> {
/// Starts the server. /// Starts the server.
/// Passing a [webSendState] additionally serves the web send (download) API. /// Passing a [webSendState] additionally serves the web send (download) API.
/// Passing [webUpload] serves the upload page so web browsers can upload files.
Future<ServerState?> startServer({ Future<ServerState?> startServer({
required String alias, required String alias,
required int port, required int port,
required bool https, required bool https,
WebSendState? webSendState, WebSendState? webSendState,
bool webUpload = false,
}) async { }) async {
if (state != null) { if (state != null) {
_logger.info('Server already running.'); _logger.info('Server already running.');
@@ -124,18 +126,25 @@ class ServerService extends Notifier<ServerState?> {
.dispatchTakeResult( .dispatchTakeResult(
IsolateHttpServerStartAction( IsolateHttpServerStartAction(
pin: settings.receivePin, pin: settings.receivePin,
webSend: webSendState != null web: webSendState != null || webUpload
? WebParams(
send: webSendState != null
? WebSendParams( ? WebSendParams(
files: { files: {
for (final entry in webSendState.files.entries) entry.key: entry.value.file.toRust(), for (final entry in webSendState.files.entries) entry.key: entry.value.file.toRust(),
}, },
pin: webSendState.pin, pin: webSendState.pin,
i18N: WebSendI18n( )
: null,
upload: webUpload,
i18N: WebI18n(
waiting: t.web.waiting, waiting: t.web.waiting,
enterPin: t.web.enterPin, enterPin: t.web.enterPin,
invalidPin: t.web.invalidPin, invalidPin: t.web.invalidPin,
tooManyAttempts: t.web.tooManyAttempts, tooManyAttempts: t.web.tooManyAttempts,
rejected: t.web.rejected, rejected: t.web.rejected,
uploadRejected: t.sendPage.rejected,
busy: t.sendPage.busy,
files: t.web.files, files: t.web.files,
fileName: t.web.fileName, fileName: t.web.fileName,
size: t.web.size, size: t.web.size,
@@ -183,6 +192,7 @@ class ServerService extends Notifier<ServerState?> {
https: https, https: https,
session: null, session: null,
webSendState: webSendState, webSendState: webSendState,
webUpload: webUpload,
); );
state = newServerState; state = newServerState;
@@ -204,9 +214,15 @@ class ServerService extends Notifier<ServerState?> {
return await startServerFromSettings(); return await startServerFromSettings();
} }
Future<ServerState?> restartServer({required String alias, required int port, required bool https, WebSendState? webSendState}) async { Future<ServerState?> restartServer({
required String alias,
required int port,
required bool https,
WebSendState? webSendState,
bool webUpload = false,
}) async {
await stopServer(); await stopServer();
return await startServer(alias: alias, port: port, https: https, webSendState: webSendState); return await startServer(alias: alias, port: port, https: https, webSendState: webSendState, webUpload: webUpload);
} }
Future<void> acceptFileRequest(Map<String, String> fileNameMap) async { Future<void> acceptFileRequest(Map<String, String> fileNameMap) async {
@@ -89,6 +89,7 @@ const _enableAnimations = 'ls_enable_animations';
const _deviceType = 'ls_device_type'; const _deviceType = 'ls_device_type';
const _deviceModel = 'ls_device_model'; const _deviceModel = 'ls_device_model';
const _shareViaLinkAutoAccept = 'ls_share_via_link_auto_accept'; const _shareViaLinkAutoAccept = 'ls_share_via_link_auto_accept';
const _receiveViaLinkAutoAccept = 'ls_receive_via_link_auto_accept';
const _advancedSettingsKey = 'ls_advanced_settings'; const _advancedSettingsKey = 'ls_advanced_settings';
const _whatsNewKey = 'ls_whats_new'; const _whatsNewKey = 'ls_whats_new';
@@ -368,6 +369,14 @@ class PersistenceService {
await _prefs.setBool(_shareViaLinkAutoAccept, shareViaLinkAutoAccept); await _prefs.setBool(_shareViaLinkAutoAccept, shareViaLinkAutoAccept);
} }
bool getReceiveViaLinkAutoAccept() {
return _prefs.getBool(_receiveViaLinkAutoAccept) ?? false;
}
Future<void> setReceiveViaLinkAutoAccept(bool receiveViaLinkAutoAccept) async {
await _prefs.setBool(_receiveViaLinkAutoAccept, receiveViaLinkAutoAccept);
}
String getMulticastGroup() { String getMulticastGroup() {
return _prefs.getString(_multicastGroupKey) ?? defaultMulticastGroup; return _prefs.getString(_multicastGroupKey) ?? defaultMulticastGroup;
} }
+9
View File
@@ -69,6 +69,7 @@ class SettingsService extends PureNotifier<SettingsState> {
deviceType: _persistence.getDeviceType(), deviceType: _persistence.getDeviceType(),
deviceModel: _persistence.getDeviceModel(), deviceModel: _persistence.getDeviceModel(),
shareViaLinkAutoAccept: _persistence.getShareViaLinkAutoAccept(), shareViaLinkAutoAccept: _persistence.getShareViaLinkAutoAccept(),
receiveViaLinkAutoAccept: _persistence.getReceiveViaLinkAutoAccept(),
discoveryTimeout: _persistence.getDiscoveryTimeout(), discoveryTimeout: _persistence.getDiscoveryTimeout(),
advancedSettings: _persistence.getAdvancedSettingsEnabled(), advancedSettings: _persistence.getAdvancedSettingsEnabled(),
); );
@@ -264,4 +265,12 @@ class SettingsService extends PureNotifier<SettingsState> {
shareViaLinkAutoAccept: shareViaLinkAutoAccept, shareViaLinkAutoAccept: shareViaLinkAutoAccept,
); );
} }
Future<void> setReceiveViaLinkAutoAccept(bool receiveViaLinkAutoAccept) async {
await _persistence.setReceiveViaLinkAutoAccept(receiveViaLinkAutoAccept);
state = state.copyWith(
receiveViaLinkAutoAccept: receiveViaLinkAutoAccept,
);
}
} }
+4 -4
View File
@@ -1315,18 +1315,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: refena name: refena
sha256: d2bb36fd39412563a4515b8228271cf8786936f77d4a6261b05eecf648e59b6b sha256: bd609a1015728db75af79df295c44ccf60507f965e0c32b447d67c48816c7d3a
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.2" version: "3.4.0"
refena_flutter: refena_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
name: refena_flutter name: refena_flutter
sha256: "18f704c3ba38a30aa05ae71b3c55777b8d09864570e3b88b2f8814644381e461" sha256: "36417ef3a60c9c134286ab3f8491d98b07f40b2163b994c4a0c905831415a6fc"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.1" version: "3.4.0"
refena_inspector: refena_inspector:
dependency: "direct dev" dependency: "direct dev"
description: description:
+1 -1
View File
@@ -48,7 +48,7 @@ dependencies:
path_provider_foundation: 2.6.0 path_provider_foundation: 2.6.0
permission_handler: 12.0.3 permission_handler: 12.0.3
pretty_qr_code: 3.6.0 pretty_qr_code: 3.6.0
refena_flutter: 3.2.1 refena_flutter: 3.4.0
refena_inspector_client: 2.1.1 refena_inspector_client: 2.1.1
routerino: 0.8.1 routerino: 0.8.1
saf_stream: 2.0.0 saf_stream: 2.0.0
+31
View File
@@ -296,6 +296,28 @@ class MockPersistenceService extends _i1.Mock implements _i3.PersistenceService
) )
as _i4.Future<void>); as _i4.Future<void>);
@override
bool getReceiveViaLinkAutoAccept() =>
(super.noSuchMethod(
Invocation.method(#getReceiveViaLinkAutoAccept, []),
returnValue: false,
returnValueForMissingStub: false,
)
as bool);
@override
_i4.Future<void> setReceiveViaLinkAutoAccept(
bool? receiveViaLinkAutoAccept,
) =>
(super.noSuchMethod(
Invocation.method(#setReceiveViaLinkAutoAccept, [
receiveViaLinkAutoAccept,
]),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
)
as _i4.Future<void>);
@override @override
String getMulticastGroup() => String getMulticastGroup() =>
(super.noSuchMethod( (super.noSuchMethod(
@@ -572,6 +594,15 @@ class MockPersistenceService extends _i1.Mock implements _i3.PersistenceService
) )
as _i4.Future<void>); as _i4.Future<void>);
@override
_i4.Future<void> setWhatsNew(String? version) =>
(super.noSuchMethod(
Invocation.method(#setWhatsNew, [version]),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
)
as _i4.Future<void>);
@override @override
_i4.Future<void> clear() => _i4.Future<void> clear() =>
(super.noSuchMethod( (super.noSuchMethod(
@@ -83,7 +83,7 @@
margin: 0 auto; margin: 0 auto;
} }
</style> </style>
<script src="main.js" defer></script> <script src="download.js" defer></script>
</head> </head>
<body> <body>
<noscript> <noscript>
+232
View File
@@ -0,0 +1,232 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LocalSend</title>
<style>
body {
font-family: sans-serif, system-ui;
margin: 0;
line-height: 1.5;
text-align: center;
}
#content {
box-sizing: border-box;
width: 100%;
max-width: 400px;
margin: 15vh auto 0;
padding: 0 1em;
}
#upload-button {
width: 100%;
padding: 1em;
font-family: inherit;
font-size: 1.5em;
color: #FFFFFF;
background-color: #00796B;
border: none;
border-radius: 0.5em;
cursor: pointer;
}
#upload-button:hover {
background-color: #009688;
}
#status-text {
display: none;
font-size: 1.5em;
}
#progress-text {
display: none;
font-size: 1.5em;
}
</style>
</head>
<body>
<noscript>
LocalSend requires JavaScript, which is currently disabled. Please enable it and try again.
</noscript>
<h1 style="padding: 0.5em">LocalSend</h1>
<div id="content">
<button id="upload-button" type="button">Upload</button>
<input id="file-input" type="file" multiple style="display: none">
<p id="status-text"></p>
<p id="progress-text"></p>
</div>
<script>
var BASE_URL = '/api/localsend/v2';
var i18n = {};
function makeRequest(url, method, body, callback) {
var xhr = new XMLHttpRequest();
xhr.open(method, url, true);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
callback(xhr);
}
};
xhr.send(body);
}
function fetchI18n(then) {
makeRequest('/i18n.json', 'GET', null, function (response) {
if (response.status === 200) {
i18n = JSON.parse(response.responseText);
then();
}
});
}
function showButton() {
document.getElementById('upload-button').style.display = 'block';
document.getElementById('status-text').style.display = 'none';
document.getElementById('progress-text').style.display = 'none';
}
function showStatus(text) {
document.getElementById('upload-button').style.display = 'none';
var statusText = document.getElementById('status-text');
statusText.style.display = 'block';
statusText.innerText = text;
}
function showProgress(finished, total) {
var progressText = document.getElementById('progress-text');
progressText.style.display = 'block';
progressText.innerText = finished + '/' + total + ' file(s)';
}
function startUpload(fileList) {
showStatus(i18n.waiting);
var selectedFiles = {};
var filesDto = {};
for (var i = 0; i < fileList.length; i++) {
var file = fileList[i];
var fileId = String(i);
selectedFiles[fileId] = file;
filesDto[fileId] = {
id: fileId,
fileName: file.name,
size: file.size,
fileType: file.type || 'application/octet-stream'
};
}
var body = JSON.stringify({
info: {
alias: 'Web Browser',
version: '2.1',
deviceType: 'web',
fingerprint: getFingerprint(),
port: 0,
protocol: location.protocol === 'https:' ? 'https' : 'http',
download: false
},
files: filesDto
});
makeRequest(BASE_URL + '/prepare-upload', 'POST', body, function (response) {
if (response.status === 403) {
showStatus(i18n.uploadRejected);
return;
}
if (response.status === 409) {
showStatus(i18n.busy);
return;
}
if (response.status === 204) {
// The recipient accepted the request but declined every file.
showStatus(i18n.uploadRejected);
return;
}
if (response.status !== 200) {
showStatus('Error: ' + response.status);
return;
}
var data = JSON.parse(response.responseText);
uploadFiles(data.sessionId, data.files, selectedFiles);
});
}
function uploadFiles(sessionId, tokens, selectedFiles) {
var fileIds = getKeys(tokens);
var total = fileIds.length;
showStatus('');
showProgress(0, total);
function uploadNext(index) {
if (index >= total) {
return;
}
var fileId = fileIds[index];
var url = BASE_URL + '/upload' +
'?sessionId=' + encodeURIComponent(sessionId) +
'&fileId=' + encodeURIComponent(fileId) +
'&token=' + encodeURIComponent(tokens[fileId]);
makeRequest(url, 'POST', selectedFiles[fileId], function (response) {
if (response.status !== 200) {
showStatus('Error: ' + response.status);
return;
}
showProgress(index + 1, total);
uploadNext(index + 1);
});
}
uploadNext(0);
}
function getFingerprint() {
var fingerprint = sessionStorage.getItem('fingerprint');
if (!fingerprint) {
fingerprint = 'web-';
for (var i = 0; i < 32; i++) {
fingerprint += Math.floor(Math.random() * 16).toString(16);
}
sessionStorage.setItem('fingerprint', fingerprint);
}
return fingerprint;
}
function getKeys(obj) {
var keys = [];
for (var key in obj) {
keys.push(key);
}
return keys;
}
function init() {
var fileInput = document.getElementById('file-input');
document.getElementById('upload-button').onclick = function () {
fileInput.click();
};
fileInput.onchange = function () {
if (fileInput.files.length > 0) {
startUpload(fileInput.files);
}
};
fetchI18n(function () {});
}
init();
</script>
</body>
</html>
@@ -11,10 +11,15 @@ use x509_parser::nom::AsBytes;
/// Enables client certificate verification. /// Enables client certificate verification.
pub(crate) struct CustomClientCertVerifier { pub(crate) struct CustomClientCertVerifier {
inner: Arc<dyn ClientCertVerifier>, inner: Arc<dyn ClientCertVerifier>,
/// Whether clients must present a certificate.
/// Optional when the web pages are served: browsers have no client certificate.
/// A certificate that is presented is always verified.
mandatory: bool,
} }
impl CustomClientCertVerifier { impl CustomClientCertVerifier {
pub(crate) fn try_new(cert: &str) -> anyhow::Result<Self> { pub(crate) fn try_new(cert: &str, mandatory: bool) -> anyhow::Result<Self> {
// We add the certificate of the server itself just so that no "empty" error is returned. // We add the certificate of the server itself just so that no "empty" error is returned.
// We don't care about the authority of the certificate, just that it is valid. // We don't care about the authority of the certificate, just that it is valid.
let mut root_cert_store = RootCertStore::empty(); let mut root_cert_store = RootCertStore::empty();
@@ -22,6 +27,7 @@ impl CustomClientCertVerifier {
Ok(Self { Ok(Self {
inner: WebPkiClientVerifier::builder(Arc::new(root_cert_store)).build()?, inner: WebPkiClientVerifier::builder(Arc::new(root_cert_store)).build()?,
mandatory,
}) })
} }
} }
@@ -38,7 +44,7 @@ impl ClientCertVerifier for CustomClientCertVerifier {
} }
fn client_auth_mandatory(&self) -> bool { fn client_auth_mandatory(&self) -> bool {
true self.mandatory
} }
fn root_hint_subjects(&self) -> &[DistinguishedName] { fn root_hint_subjects(&self) -> &[DistinguishedName] {
+38 -11
View File
@@ -10,7 +10,7 @@ pub use peer_ip::PeerIp;
use crate::crypto::cert::{fingerprint_from_cert_der, public_key_from_cert_der}; use crate::crypto::cert::{fingerprint_from_cert_der, public_key_from_cert_der};
use crate::http::server::internal::{InternalConfig, InternalState}; use crate::http::server::internal::{InternalConfig, InternalState};
use crate::http::server::v2::ServerEventV2; use crate::http::server::v2::ServerEventV2;
use crate::http::server::web::WebSendConfig; use crate::http::server::web::{WebConfig, WebI18n};
use crate::http::state::ClientInfo; use crate::http::state::ClientInfo;
use common::client_cert_verifier::CustomClientCertVerifier; use common::client_cert_verifier::CustomClientCertVerifier;
use common::error::AppError; use common::error::AppError;
@@ -63,9 +63,15 @@ pub struct AppState {
/// Information about server's device. /// Information about server's device.
info: Arc<Mutex<ClientInfo>>, info: Arc<Mutex<ClientInfo>>,
/// State for serving web pages. /// State for serving the download page (web send).
web: Option<Arc<WebPageState>>, web: Option<Arc<WebPageState>>,
/// Whether the upload page is served (when the download page is not active).
web_upload: bool,
/// Translations for the web pages, served via `/i18n.json`.
web_i18n: Option<Arc<WebI18n>>,
/// State for application-internal endpoints. /// State for application-internal endpoints.
internal: Option<Arc<InternalState>>, internal: Option<Arc<InternalState>>,
@@ -84,7 +90,7 @@ impl AppState {
info: Arc<Mutex<ClientInfo>>, info: Arc<Mutex<ClientInfo>>,
internal_config: Option<InternalConfig>, internal_config: Option<InternalConfig>,
v2_config: Option<ServerConfigV2>, v2_config: Option<ServerConfigV2>,
web_send_config: Option<WebSendConfig>, web_config: Option<WebConfig>,
) -> Self { ) -> Self {
let v2 = v2_config.map(|config| { let v2 = v2_config.map(|config| {
Arc::new(V2State { Arc::new(V2State {
@@ -95,12 +101,21 @@ impl AppState {
}) })
}); });
let web = web_send_config.map(|config| Arc::new(WebPageState::new(config))); let (web, web_upload, web_i18n) = match web_config {
Some(config) => (
config.send.map(|send| Arc::new(WebPageState::new(send))),
config.upload,
Some(Arc::new(config.i18n)),
),
None => (None, false, None),
};
let internal = internal_config.map(|config| Arc::new(InternalState::new(config))); let internal = internal_config.map(|config| Arc::new(InternalState::new(config)));
Self { Self {
info, info,
web, web,
web_upload,
web_i18n,
internal, internal,
received_nonce_map: Arc::new(Mutex::new(LruCache::new( received_nonce_map: Arc::new(Mutex::new(LruCache::new(
NonZeroUsize::new(200).unwrap(), NonZeroUsize::new(200).unwrap(),
@@ -200,12 +215,12 @@ pub async fn start_with_port(
info: ClientInfo, info: ClientInfo,
internal_config: Option<InternalConfig>, internal_config: Option<InternalConfig>,
v2_config: Option<ServerConfigV2>, v2_config: Option<ServerConfigV2>,
web_send_config: Option<WebSendConfig>, web_config: Option<WebConfig>,
stop_rx: oneshot::Receiver<()>, stop_rx: oneshot::Receiver<()>,
) -> anyhow::Result<ServerHandle> { ) -> anyhow::Result<ServerHandle> {
let ipv4_socket_addr = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), port); let ipv4_socket_addr = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), port);
let info = Arc::new(Mutex::new(info)); let info = Arc::new(Mutex::new(info));
let state = AppState::new(info.clone(), internal_config, v2_config, web_send_config); let state = AppState::new(info.clone(), internal_config, v2_config, web_config);
let ipv4_listener = tokio::net::TcpListener::bind(ipv4_socket_addr).await?; let ipv4_listener = tokio::net::TcpListener::bind(ipv4_socket_addr).await?;
// With port 0, the IPv6 listener must reuse the port the IPv4 listener got. // With port 0, the IPv6 listener must reuse the port the IPv4 listener got.
@@ -293,10 +308,16 @@ async fn start_server_with_listener(
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
// Browsers have no client certificate, so presenting one is optional while
// the web pages are served. A certificate that is presented is still verified.
let mandatory_client_auth = app_state.web.is_none() && !app_state.web_upload;
let tls_acceptor = match tls_config { let tls_acceptor = match tls_config {
Some(tls_config) => Some(create_tls_config(&tls_config).inspect_err(|err| { Some(tls_config) => Some(
create_tls_config(&tls_config, mandatory_client_auth).inspect_err(|err| {
tracing::error!("failed to create tls config: {err:#}"); tracing::error!("failed to create tls config: {err:#}");
})?), })?,
),
None => None, None => None,
}; };
@@ -343,11 +364,13 @@ async fn serve_connection(
let (_, server_connection) = tls_stream.get_ref(); let (_, server_connection) = tls_stream.get_ref();
RequestClientInfo { RequestClientInfo {
ip: PeerIp::from_remote_addr(&remote_addr), ip: PeerIp::from_remote_addr(&remote_addr),
// No certificate when client auth is optional (web pages served)
// and the client (e.g. a browser) did not present one.
cert: server_connection cert: server_connection
.deref() .deref()
.deref() .deref()
.peer_certificates() .peer_certificates()
.map(|cert| cert.get(0).unwrap().to_vec()), .and_then(|certs| certs.first().map(|cert| cert.to_vec())),
} }
}; };
@@ -386,7 +409,10 @@ async fn serve_connection(
} }
} }
fn create_tls_config(tls_config: &TlsConfig) -> anyhow::Result<tokio_rustls::TlsAcceptor> { fn create_tls_config(
tls_config: &TlsConfig,
mandatory_client_auth: bool,
) -> anyhow::Result<tokio_rustls::TlsAcceptor> {
let config = { let config = {
let certs = vec![CertificateDer::from_pem_slice(&tls_config.cert.as_bytes())?]; let certs = vec![CertificateDer::from_pem_slice(&tls_config.cert.as_bytes())?];
let key = PrivateKeyDer::from_pem_slice(&tls_config.private_key.as_bytes())?; let key = PrivateKeyDer::from_pem_slice(&tls_config.private_key.as_bytes())?;
@@ -394,6 +420,7 @@ fn create_tls_config(tls_config: &TlsConfig) -> anyhow::Result<tokio_rustls::Tls
rustls::ServerConfig::builder() rustls::ServerConfig::builder()
.with_client_cert_verifier(Arc::new(CustomClientCertVerifier::try_new( .with_client_cert_verifier(Arc::new(CustomClientCertVerifier::try_new(
&tls_config.cert, &tls_config.cert,
mandatory_client_auth,
)?)) )?))
.with_single_cert(certs, key)? .with_single_cert(certs, key)?
}; };
@@ -456,7 +483,7 @@ async fn handle_request_inner(mut req: Request<Incoming>) -> Result<Response<Box
match (req.method(), req.uri().path()) { match (req.method(), req.uri().path()) {
(&Method::GET, "/") => Ok(web::index(&state)), (&Method::GET, "/") => Ok(web::index(&state)),
(&Method::GET, "/main.js") => Ok(web::main_js(&state)), (&Method::GET, "/download.js") => Ok(web::download_js(&state)),
(&Method::GET, "/i18n.json") => web::i18n(&state), (&Method::GET, "/i18n.json") => web::i18n(&state),
(&Method::POST, "/api/localsend/v2/prepare-download") => { (&Method::POST, "/api/localsend/v2/prepare-download") => {
web::prepare_download(req, state, client_info).await web::prepare_download(req, state, client_info).await
+44 -19
View File
@@ -67,8 +67,9 @@ pub enum WebSendEvent {
}, },
} }
const INDEX_HTML: &str = include_str!("../../../assets/web/index.html"); const DOWNLOAD_HTML: &str = include_str!("../../../assets/web/download.html");
const MAIN_JS: &str = include_str!("../../../assets/web/main.js"); const DOWNLOAD_JS: &str = include_str!("../../../assets/web/download.js");
const UPLOAD_HTML: &str = include_str!("../../../assets/web/upload.html");
const ERROR_403_HTML: &str = include_str!("../../../assets/web/error-403.html"); const ERROR_403_HTML: &str = include_str!("../../../assets/web/error-403.html");
/// Characters that are percent-encoded in the content-disposition file name. /// Characters that are percent-encoded in the content-disposition file name.
@@ -84,6 +85,21 @@ const FILE_NAME_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'(') .remove(b'(')
.remove(b')'); .remove(b')');
/// Configuration for the web pages served to browsers.
pub struct WebConfig {
/// Enables web send (the download page): files offered for download by web browsers.
/// `None` disables the download page and the download API.
pub send: Option<WebSendConfig>,
/// Serves the upload page so web browsers can upload files
/// via the v2 `prepare-upload`/`upload` endpoints.
/// Ignored when [`WebConfig::send`] is set: the download page takes precedence at `/`.
pub upload: bool,
/// Translations for the web pages, served via `/i18n.json`.
pub i18n: WebI18n,
}
/// Configuration for web send (download API): files offered for download by web browsers. /// Configuration for web send (download API): files offered for download by web browsers.
/// ///
/// Web send can be enabled independently of the v2/v3 protocol endpoints. /// Web send can be enabled independently of the v2/v3 protocol endpoints.
@@ -97,28 +113,27 @@ pub struct WebSendConfig {
/// Optional PIN that web clients must provide via the `pin` query parameter. /// Optional PIN that web clients must provide via the `pin` query parameter.
pub pin: Option<String>, pub pin: Option<String>,
/// Translations for the web page, served via `/i18n.json`.
pub i18n: WebSendI18n,
/// Channel on which the server emits events that must be handled by the application. /// Channel on which the server emits events that must be handled by the application.
pub event_tx: mpsc::Sender<WebSendEvent>, pub event_tx: mpsc::Sender<WebSendEvent>,
} }
/// Translations for the web page, served via `/i18n.json`. /// Translations for the web pages, served via `/i18n.json`.
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct WebSendI18n { pub struct WebI18n {
pub waiting: String, pub waiting: String,
pub enter_pin: String, pub enter_pin: String,
pub invalid_pin: String, pub invalid_pin: String,
pub too_many_attempts: String, pub too_many_attempts: String,
pub rejected: String, pub rejected: String,
pub upload_rejected: String,
pub busy: String,
pub files: String, pub files: String,
pub file_name: String, pub file_name: String,
pub size: String, pub size: String,
} }
impl Default for WebSendI18n { impl Default for WebI18n {
fn default() -> Self { fn default() -> Self {
Self { Self {
waiting: "Waiting for response…".to_string(), waiting: "Waiting for response…".to_string(),
@@ -126,6 +141,8 @@ impl Default for WebSendI18n {
invalid_pin: "Invalid PIN".to_string(), invalid_pin: "Invalid PIN".to_string(),
too_many_attempts: "Too many attempts".to_string(), too_many_attempts: "Too many attempts".to_string(),
rejected: "Rejected".to_string(), rejected: "Rejected".to_string(),
upload_rejected: "The recipient has rejected the request.".to_string(),
busy: "The recipient is busy with another request.".to_string(),
files: "Files".to_string(), files: "Files".to_string(),
file_name: "File name".to_string(), file_name: "File name".to_string(),
size: "Size".to_string(), size: "Size".to_string(),
@@ -141,9 +158,6 @@ pub(crate) struct WebPageState {
/// Optional PIN required for prepare-download requests. /// Optional PIN required for prepare-download requests.
pub(crate) pin: Option<String>, pub(crate) pin: Option<String>,
/// Translations served via `/i18n.json`.
pub(crate) i18n: WebSendI18n,
/// Channel on which server events are emitted to the application. /// Channel on which server events are emitted to the application.
pub(crate) event_tx: mpsc::Sender<WebSendEvent>, pub(crate) event_tx: mpsc::Sender<WebSendEvent>,
@@ -159,7 +173,6 @@ impl WebPageState {
Self { Self {
files: config.files, files: config.files,
pin: config.pin, pin: config.pin,
i18n: config.i18n,
event_tx: config.event_tx, event_tx: config.event_tx,
sessions: Mutex::new(HashMap::new()), sessions: Mutex::new(HashMap::new()),
pin_attempts: Mutex::new(LruCache::new(NonZeroUsize::new(200).unwrap())), pin_attempts: Mutex::new(LruCache::new(NonZeroUsize::new(200).unwrap())),
@@ -177,25 +190,37 @@ pub(crate) struct WebSendSession {
} }
pub(crate) fn index(state: &AppState) -> Response<BoxedBody> { pub(crate) fn index(state: &AppState) -> Response<BoxedBody> {
match &state.web { if state.web.is_some() {
Some(_) => html_response(StatusCode::OK, INDEX_HTML, "text/html; charset=utf-8"), html_response(StatusCode::OK, DOWNLOAD_HTML, "text/html; charset=utf-8")
None => error_403_page(), } else if state.web_upload {
html_response(StatusCode::OK, UPLOAD_HTML, "text/html; charset=utf-8")
} else {
error_403_page()
} }
} }
pub(crate) fn main_js(state: &AppState) -> Response<BoxedBody> { pub(crate) fn download_js(state: &AppState) -> Response<BoxedBody> {
match &state.web { match &state.web {
Some(_) => html_response(StatusCode::OK, MAIN_JS, "text/javascript; charset=utf-8"), Some(_) => html_response(
StatusCode::OK,
DOWNLOAD_JS,
"text/javascript; charset=utf-8",
),
None => error_403_page(), None => error_403_page(),
} }
} }
pub(crate) fn i18n(state: &AppState) -> Result<Response<BoxedBody>, AppError> { pub(crate) fn i18n(state: &AppState) -> Result<Response<BoxedBody>, AppError> {
let web = require_web(state)?; let Some(i18n) = &state.web_i18n else {
return Err(AppError::Message(
StatusCode::FORBIDDEN,
"Web pages not initialized.".to_string(),
));
};
Ok(JsonResponse { Ok(JsonResponse {
status: StatusCode::OK, status: StatusCode::OK,
body: &web.i18n, body: i18n.as_ref(),
} }
.into_response()) .into_response())
} }
+72 -1
View File
@@ -9,6 +9,7 @@ use localsend::http::client::{ClientError, LsHttpClientV2};
use localsend::http::dto_v2::{PrepareUploadRequestDtoV2, RegisterDtoV2}; use localsend::http::dto_v2::{PrepareUploadRequestDtoV2, RegisterDtoV2};
use localsend::http::server::common::save::FileUploadTarget; use localsend::http::server::common::save::FileUploadTarget;
use localsend::http::server::v2::{PrepareUploadDecisionV2, ServerEventV2}; use localsend::http::server::v2::{PrepareUploadDecisionV2, ServerEventV2};
use localsend::http::server::web::{WebConfig, WebI18n};
use localsend::http::server::{start_with_port, ServerConfigV2, TlsConfig}; use localsend::http::server::{start_with_port, ServerConfigV2, TlsConfig};
use localsend::http::state::ClientInfo; use localsend::http::state::ClientInfo;
use localsend::model::discovery::ProtocolType; use localsend::model::discovery::ProtocolType;
@@ -49,6 +50,12 @@ struct TestServer {
/// Starts a test server over TLS using the given identity. /// Starts a test server over TLS using the given identity.
async fn start_tls_server(identity: &Identity) -> TestServer { async fn start_tls_server(identity: &Identity) -> TestServer {
start_tls_server_with_web(identity, None).await
}
/// Starts a test server over TLS, optionally with the web pages enabled
/// (which makes the client certificate optional).
async fn start_tls_server_with_web(identity: &Identity, web: Option<WebConfig>) -> TestServer {
let _ = tracing_subscriber::fmt().with_test_writer().try_init(); let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let port = free_port(); let port = free_port();
let prepare_uploads: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new())); let prepare_uploads: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
@@ -118,7 +125,7 @@ async fn start_tls_server(identity: &Identity) -> TestServer {
pin: None, pin: None,
event_tx, event_tx,
}), }),
None, web,
stop_rx, stop_rx,
) )
.await .await
@@ -365,6 +372,70 @@ async fn test_upload_body_not_sent_on_fingerprint_mismatch() {
assert!(server.received.lock().await.is_empty()); assert!(server.received.lock().await.is_empty());
} }
/// Without the web pages, the client certificate stays mandatory: a client
/// without one (e.g. a browser) must fail the handshake.
#[tokio::test]
async fn test_client_without_cert_rejected() {
let server_identity = generate_identity();
let server = start_tls_server(&server_identity).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let result = client
.info(ProtocolType::Https, "127.0.0.1", server.port)
.await;
assert!(
matches!(result, Err(ClientError::Reqwest(_))),
"expected the handshake to fail, got {:?}",
result.err()
);
}
/// With the web pages served, the client certificate is optional so that
/// browsers can connect; a presented certificate is still verified (mTLS
/// clients keep working).
#[tokio::test]
async fn test_client_without_cert_allowed_in_web_mode() {
let server_identity = generate_identity();
let sender = generate_identity();
let server = start_tls_server_with_web(
&server_identity,
Some(WebConfig {
send: None,
upload: true,
i18n: WebI18n::default(),
}),
)
.await;
// A browser-like client: no client certificate, self-signed server cert accepted.
let browser = localsend::reqwest::Client::builder()
.use_rustls_tls()
.danger_accept_invalid_certs(true)
.build()
.unwrap();
let response = browser
.get(format!("https://127.0.0.1:{}/", server.port))
.send()
.await
.expect("browser without client certificate should be able to connect");
assert_eq!(response.status().as_u16(), 200);
assert!(response.text().await.unwrap().contains("prepare-upload"));
// A LocalSend peer still authenticates with its certificate.
let client = client(&sender, Some(&server_identity.fingerprint));
let response = client
.register(
ProtocolType::Https,
"127.0.0.1",
server.port,
sender_info(&sender.fingerprint),
)
.await
.expect("register with client certificate should succeed");
assert!(response.public_key.is_some());
}
/// Discovery has no fingerprint to pin yet, so any valid certificate is /// Discovery has no fingerprint to pin yet, so any valid certificate is
/// accepted and the public key is read from the response. /// accepted and the public key is read from the response.
#[tokio::test] #[tokio::test]
+72 -6
View File
@@ -4,7 +4,7 @@ use bytes::Bytes;
use localsend::http::client::{ClientError, LsHttpClientV2}; use localsend::http::client::{ClientError, LsHttpClientV2};
use localsend::http::server::v2::ServerEventV2; use localsend::http::server::v2::ServerEventV2;
use localsend::http::server::web::WebSendConfig; use localsend::http::server::web::WebSendConfig;
use localsend::http::server::web::{WebSendEvent, WebSendI18n}; use localsend::http::server::web::{WebConfig, WebI18n, WebSendEvent};
use localsend::http::server::{start_with_port, ServerConfigV2}; use localsend::http::server::{start_with_port, ServerConfigV2};
use localsend::http::state::ClientInfo; use localsend::http::state::ClientInfo;
use localsend::model::discovery::ProtocolType; use localsend::model::discovery::ProtocolType;
@@ -118,9 +118,13 @@ async fn start_test_server(
let (stop_tx, stop_rx) = oneshot::channel::<()>(); let (stop_tx, stop_rx) = oneshot::channel::<()>();
// Web send is configured independently of the v2 endpoints. // Web send is configured independently of the v2 endpoints.
let web_send = web_send.map(|mut config| { let web_config = web_send.map(|mut config| {
config.event_tx = web_event_tx; config.event_tx = web_event_tx;
config WebConfig {
send: Some(config),
upload: false,
i18n: WebI18n::default(),
}
}); });
start_with_port( start_with_port(
@@ -138,7 +142,7 @@ async fn start_test_server(
pin: None, pin: None,
event_tx: v2_event_tx, event_tx: v2_event_tx,
}), }),
web_send, web_config,
stop_rx, stop_rx,
) )
.await .await
@@ -242,7 +246,6 @@ fn web_send_config(
WebSendConfig { WebSendConfig {
files, files,
pin, pin,
i18n: WebSendI18n::default(),
event_tx, event_tx,
}, },
contents, contents,
@@ -271,7 +274,7 @@ async fn test_web_page() {
assert!(response.text().await.unwrap().contains("LocalSend")); assert!(response.text().await.unwrap().contains("LocalSend"));
let response = client let response = client
.get(format!("{base_url}/main.js")) .get(format!("{base_url}/download.js"))
.send() .send()
.await .await
.unwrap(); .unwrap();
@@ -326,6 +329,69 @@ async fn test_web_page_disabled() {
assert!(!info.download); assert!(!info.download);
} }
#[tokio::test]
async fn test_upload_page() {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let port = free_port();
let (v2_event_tx, _v2_event_rx) = mpsc::channel::<ServerEventV2>(16);
let (_stop_tx, stop_rx) = oneshot::channel::<()>();
start_with_port(
port,
None, // plain HTTP
ClientInfo {
alias: "Test Server".to_string(),
version: "2.1".to_string(),
device_model: Some("Rust".to_string()),
device_type: None,
token: "server-fingerprint".to_string(),
},
None,
Some(ServerConfigV2 {
pin: None,
event_tx: v2_event_tx,
}),
Some(WebConfig {
send: None,
upload: true,
i18n: WebI18n::default(),
}),
stop_rx,
)
.await
.expect("Failed to start server");
wait_until_reachable(port).await;
let client = localsend::reqwest::Client::new();
let base_url = format!("http://127.0.0.1:{port}");
// The upload page is served at `/` because web send is not active.
let response = client.get(&base_url).send().await.unwrap();
assert_eq!(response.status().as_u16(), 200);
let body = response.text().await.unwrap();
assert!(body.contains("LocalSend"));
assert!(body.contains("prepare-upload"));
let response = client
.get(format!("{base_url}/i18n.json"))
.send()
.await
.unwrap();
assert_eq!(response.status().as_u16(), 200);
let i18n = response.json::<HashMap<String, String>>().await.unwrap();
assert!(i18n.contains_key("busy"));
assert!(i18n.contains_key("uploadRejected"));
// The download page assets stay disabled without web send.
let response = client
.get(format!("{base_url}/download.js"))
.send()
.await
.unwrap();
assert_eq!(response.status().as_u16(), 403);
}
#[tokio::test] #[tokio::test]
async fn test_full_download_flow() { async fn test_full_download_flow() {
let (config, contents, disk_path, disk_content) = web_send_config(None); let (config, contents, disk_path, disk_content) = web_send_config(None);
@@ -7,7 +7,8 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:localsend_isolates/rust/api/model.dart'; import 'package:localsend_isolates/rust/api/model.dart';
import 'package:localsend_isolates/rust/frb_generated.dart'; import 'package:localsend_isolates/rust/frb_generated.dart';
// These functions are ignored because they are not marked as `pub`: `rs_stored_device` // These functions are ignored because they are not marked as `pub`: `rs_stored_device`, `stop`
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `DiscoveryInstance`
/// Starts the discovery: binds the UDP multicast sockets on all usable /// Starts the discovery: binds the UDP multicast sockets on all usable
/// network interfaces, answers announcements of other devices with an HTTP /// network interfaces, answers announcements of other devices with an HTTP
@@ -88,6 +89,9 @@ abstract class RsDiscovery implements RustOpaqueInterface {
/// Emits a [RsStoredDevice] for every device confirmation until the /// Emits a [RsStoredDevice] for every device confirmation until the
/// discovery is stopped. Can only be listened to once. /// discovery is stopped. Can only be listened to once.
///
/// Also returns when the Dart side of the stream is gone (e.g. after a
/// hot restart), so this call does not keep the discovery alive forever.
Stream<RsStoredDevice> listen(); Stream<RsStoredDevice> listen();
/// The reason the multicast sockets could not be bound, when they could /// The reason the multicast sockets could not be bound, when they could
@@ -10,13 +10,15 @@ import 'package:localsend_isolates/rust/frb_generated.dart';
part 'server.freezed.dart'; part 'server.freezed.dart';
// These functions are ignored because they are not marked as `pub`: `handle_server_event`, `handle_web_event`, `recv_opt`, `resolve_file_content`, `resolve_upload_target` // These functions are ignored because they are not marked as `pub`: `handle_server_event`, `handle_web_event`, `recv_opt`, `resolve_file_content`, `resolve_upload_target`, `stop`
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `ServerInstance`
/// Starts the HTTP server on the given port (IPv4 and IPv6). /// Starts the HTTP server on the given port (IPv4 and IPv6).
/// The server runs until [RsHttpServer::stop] is called. /// The server runs until [RsHttpServer::stop] is called.
/// ///
/// Passing [web_send] additionally enables the web send (download API) so that /// Passing [web] additionally serves the web pages: the download page when
/// web browsers can download the offered files. /// [WebParams::send] is set (so web browsers can download the offered files)
/// or the upload page when [WebParams::upload] is enabled.
/// ///
/// Passing [show_token] enables the internal `show` endpoint that lets another /// Passing [show_token] enables the internal `show` endpoint that lets another
/// application instance request this one to show itself (emitted as /// application instance request this one to show itself (emitted as
@@ -32,7 +34,7 @@ Future<RsHttpServer> startServer({
DeviceType? deviceType, DeviceType? deviceType,
required String fingerprint, required String fingerprint,
String? pin, String? pin,
WebSendParams? webSend, WebParams? web,
String? showToken, String? showToken,
}) => RustLib.instance.api.crateApiServerStartServer( }) => RustLib.instance.api.crateApiServerStartServer(
port: port, port: port,
@@ -43,7 +45,7 @@ Future<RsHttpServer> startServer({
deviceType: deviceType, deviceType: deviceType,
fingerprint: fingerprint, fingerprint: fingerprint,
pin: pin, pin: pin,
webSend: webSend, web: web,
showToken: showToken, showToken: showToken,
); );
@@ -77,6 +79,9 @@ abstract class RsHttpServer implements RustOpaqueInterface {
/// ///
/// The v2 protocol, the web send (download API), and the internal endpoint /// The v2 protocol, the web send (download API), and the internal endpoint
/// events are all emitted on the same stream. /// events are all emitted on the same stream.
///
/// Also returns when the Dart side of the stream is gone (e.g. after a
/// hot restart), so this call does not keep the server alive forever.
Stream<RsServerEvent> listen(); Stream<RsServerEvent> listen();
/// Answers the pending [RsServerEvent::WebFileDownload] event with the source /// Answers the pending [RsServerEvent::WebFileDownload] event with the source
@@ -266,22 +271,26 @@ class TlsConfig {
identical(this, other) || other is TlsConfig && runtimeType == other.runtimeType && cert == other.cert && privateKey == other.privateKey; identical(this, other) || other is TlsConfig && runtimeType == other.runtimeType && cert == other.cert && privateKey == other.privateKey;
} }
class WebSendI18n { class WebI18n {
final String waiting; final String waiting;
final String enterPin; final String enterPin;
final String invalidPin; final String invalidPin;
final String tooManyAttempts; final String tooManyAttempts;
final String rejected; final String rejected;
final String uploadRejected;
final String busy;
final String files; final String files;
final String fileName; final String fileName;
final String size; final String size;
const WebSendI18n({ const WebI18n({
required this.waiting, required this.waiting,
required this.enterPin, required this.enterPin,
required this.invalidPin, required this.invalidPin,
required this.tooManyAttempts, required this.tooManyAttempts,
required this.rejected, required this.rejected,
required this.uploadRejected,
required this.busy,
required this.files, required this.files,
required this.fileName, required this.fileName,
required this.size, required this.size,
@@ -294,6 +303,8 @@ class WebSendI18n {
invalidPin.hashCode ^ invalidPin.hashCode ^
tooManyAttempts.hashCode ^ tooManyAttempts.hashCode ^
rejected.hashCode ^ rejected.hashCode ^
uploadRejected.hashCode ^
busy.hashCode ^
files.hashCode ^ files.hashCode ^
fileName.hashCode ^ fileName.hashCode ^
size.hashCode; size.hashCode;
@@ -301,22 +312,53 @@ class WebSendI18n {
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
other is WebSendI18n && other is WebI18n &&
runtimeType == other.runtimeType && runtimeType == other.runtimeType &&
waiting == other.waiting && waiting == other.waiting &&
enterPin == other.enterPin && enterPin == other.enterPin &&
invalidPin == other.invalidPin && invalidPin == other.invalidPin &&
tooManyAttempts == other.tooManyAttempts && tooManyAttempts == other.tooManyAttempts &&
rejected == other.rejected && rejected == other.rejected &&
uploadRejected == other.uploadRejected &&
busy == other.busy &&
files == other.files && files == other.files &&
fileName == other.fileName && fileName == other.fileName &&
size == other.size; size == other.size;
} }
/// Configuration for the web pages served to browsers. When omitted, the web
/// pages respond with 403 and only the v2 endpoints run.
class WebParams {
/// Enables web send (the download page): files offered for download by web
/// browsers. `null` disables the download page and the download API.
final WebSendParams? send;
/// Serves the upload page so web browsers can upload files via the v2
/// `prepare-upload`/`upload` endpoints. Ignored when [WebParams::send] is
/// set: the download page takes precedence at `/`.
final bool upload;
/// Translations for the web pages, served via `/i18n.json`.
final WebI18n i18N;
const WebParams({
this.send,
required this.upload,
required this.i18N,
});
@override
int get hashCode => send.hashCode ^ upload.hashCode ^ i18N.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is WebParams && runtimeType == other.runtimeType && send == other.send && upload == other.upload && i18N == other.i18N;
}
/// Configuration for web send: files offered for download by web browsers. /// Configuration for web send: files offered for download by web browsers.
/// ///
/// Web send can be enabled independently of the v2 protocol endpoints. When /// Web send can be enabled independently of the v2 protocol endpoints.
/// omitted, the download API responds with 403 and only the v2 endpoints run.
class WebSendParams { class WebSendParams {
/// The metadata of the files offered for download, mapped by file ID. /// The metadata of the files offered for download, mapped by file ID.
/// The content is requested per download via [RsServerEvent::WebFileDownload]. /// The content is requested per download via [RsServerEvent::WebFileDownload].
@@ -325,20 +367,15 @@ class WebSendParams {
/// Optional PIN that web clients must provide via the `pin` query parameter. /// Optional PIN that web clients must provide via the `pin` query parameter.
final String? pin; final String? pin;
/// Translations for the web page, served via `/i18n.json`.
final WebSendI18n i18N;
const WebSendParams({ const WebSendParams({
required this.files, required this.files,
this.pin, this.pin,
required this.i18N,
}); });
@override @override
int get hashCode => files.hashCode ^ pin.hashCode ^ i18N.hashCode; int get hashCode => files.hashCode ^ pin.hashCode;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) || other is WebSendParams && runtimeType == other.runtimeType && files == other.files && pin == other.pin;
other is WebSendParams && runtimeType == other.runtimeType && files == other.files && pin == other.pin && i18N == other.i18N;
} }
@@ -302,7 +302,7 @@ abstract class RustLibApi extends BaseApi {
DeviceType? deviceType, DeviceType? deviceType,
required String fingerprint, required String fingerprint,
String? pin, String? pin,
WebSendParams? webSend, WebParams? web,
String? showToken, String? showToken,
}); });
@@ -2043,7 +2043,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
DeviceType? deviceType, DeviceType? deviceType,
required String fingerprint, required String fingerprint,
String? pin, String? pin,
WebSendParams? webSend, WebParams? web,
String? showToken, String? showToken,
}) { }) {
return handler.executeNormal( return handler.executeNormal(
@@ -2058,7 +2058,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_opt_box_autoadd_device_type(deviceType, serializer); sse_encode_opt_box_autoadd_device_type(deviceType, serializer);
sse_encode_String(fingerprint, serializer); sse_encode_String(fingerprint, serializer);
sse_encode_opt_String(pin, serializer); sse_encode_opt_String(pin, serializer);
sse_encode_opt_box_autoadd_web_send_params(webSend, serializer); sse_encode_opt_box_autoadd_web_params(web, serializer);
sse_encode_opt_String(showToken, serializer); sse_encode_opt_String(showToken, serializer);
pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 55, port: port_); pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 55, port: port_);
}, },
@@ -2067,7 +2067,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
decodeErrorData: sse_decode_AnyhowException, decodeErrorData: sse_decode_AnyhowException,
), ),
constMeta: kCrateApiServerStartServerConstMeta, constMeta: kCrateApiServerStartServerConstMeta,
argValues: [port, tls, alias, version, deviceModel, deviceType, fingerprint, pin, webSend, showToken], argValues: [port, tls, alias, version, deviceModel, deviceType, fingerprint, pin, web, showToken],
apiImpl: this, apiImpl: this,
), ),
); );
@@ -2075,7 +2075,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiServerStartServerConstMeta => const TaskConstMeta( TaskConstMeta get kCrateApiServerStartServerConstMeta => const TaskConstMeta(
debugName: 'start_server', debugName: 'start_server',
argNames: ['port', 'tls', 'alias', 'version', 'deviceModel', 'deviceType', 'fingerprint', 'pin', 'webSend', 'showToken'], argNames: ['port', 'tls', 'alias', 'version', 'deviceModel', 'deviceType', 'fingerprint', 'pin', 'web', 'showToken'],
); );
@override @override
@@ -2627,6 +2627,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return raw as int; return raw as int;
} }
@protected
WebParams dco_decode_box_autoadd_web_params(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return dco_decode_web_params(raw);
}
@protected @protected
WebSendParams dco_decode_box_autoadd_web_send_params(dynamic raw) { WebSendParams dco_decode_box_autoadd_web_send_params(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs // Codec=Dco (DartCObject based), see doc to use other codecs
@@ -2864,6 +2870,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return raw == null ? null : dco_decode_box_autoadd_u_32(raw); return raw == null ? null : dco_decode_box_autoadd_u_32(raw);
} }
@protected
WebParams? dco_decode_opt_box_autoadd_web_params(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw == null ? null : dco_decode_box_autoadd_web_params(raw);
}
@protected @protected
WebSendParams? dco_decode_opt_box_autoadd_web_send_params(dynamic raw) { WebSendParams? dco_decode_opt_box_autoadd_web_send_params(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs // Codec=Dco (DartCObject based), see doc to use other codecs
@@ -3315,19 +3327,33 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
} }
@protected @protected
WebSendI18n dco_decode_web_send_i_18_n(dynamic raw) { WebI18n dco_decode_web_i_18_n(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs // Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>; final arr = raw as List<dynamic>;
if (arr.length != 8) throw Exception('unexpected arr length: expect 8 but see ${arr.length}'); if (arr.length != 10) throw Exception('unexpected arr length: expect 10 but see ${arr.length}');
return WebSendI18n( return WebI18n(
waiting: dco_decode_String(arr[0]), waiting: dco_decode_String(arr[0]),
enterPin: dco_decode_String(arr[1]), enterPin: dco_decode_String(arr[1]),
invalidPin: dco_decode_String(arr[2]), invalidPin: dco_decode_String(arr[2]),
tooManyAttempts: dco_decode_String(arr[3]), tooManyAttempts: dco_decode_String(arr[3]),
rejected: dco_decode_String(arr[4]), rejected: dco_decode_String(arr[4]),
files: dco_decode_String(arr[5]), uploadRejected: dco_decode_String(arr[5]),
fileName: dco_decode_String(arr[6]), busy: dco_decode_String(arr[6]),
size: dco_decode_String(arr[7]), files: dco_decode_String(arr[7]),
fileName: dco_decode_String(arr[8]),
size: dco_decode_String(arr[9]),
);
}
@protected
WebParams dco_decode_web_params(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 3) throw Exception('unexpected arr length: expect 3 but see ${arr.length}');
return WebParams(
send: dco_decode_opt_box_autoadd_web_send_params(arr[0]),
upload: dco_decode_bool(arr[1]),
i18N: dco_decode_web_i_18_n(arr[2]),
); );
} }
@@ -3335,11 +3361,10 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
WebSendParams dco_decode_web_send_params(dynamic raw) { WebSendParams dco_decode_web_send_params(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs // Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>; final arr = raw as List<dynamic>;
if (arr.length != 3) throw Exception('unexpected arr length: expect 3 but see ${arr.length}'); if (arr.length != 2) throw Exception('unexpected arr length: expect 2 but see ${arr.length}');
return WebSendParams( return WebSendParams(
files: dco_decode_Map_String_file_dto_None(arr[0]), files: dco_decode_Map_String_file_dto_None(arr[0]),
pin: dco_decode_opt_String(arr[1]), pin: dco_decode_opt_String(arr[1]),
i18N: dco_decode_web_send_i_18_n(arr[2]),
); );
} }
@@ -3844,6 +3869,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return (sse_decode_u_32(deserializer)); return (sse_decode_u_32(deserializer));
} }
@protected
WebParams sse_decode_box_autoadd_web_params(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
return (sse_decode_web_params(deserializer));
}
@protected @protected
WebSendParams sse_decode_box_autoadd_web_send_params(SseDeserializer deserializer) { WebSendParams sse_decode_box_autoadd_web_send_params(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
@@ -4166,6 +4197,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
} }
} }
@protected
WebParams? sse_decode_opt_box_autoadd_web_params(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
if (sse_decode_bool(deserializer)) {
return (sse_decode_box_autoadd_web_params(deserializer));
} else {
return null;
}
}
@protected @protected
WebSendParams? sse_decode_opt_box_autoadd_web_send_params(SseDeserializer deserializer) { WebSendParams? sse_decode_opt_box_autoadd_web_send_params(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
@@ -4610,35 +4652,47 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
} }
@protected @protected
WebSendI18n sse_decode_web_send_i_18_n(SseDeserializer deserializer) { WebI18n sse_decode_web_i_18_n(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
var var_waiting = sse_decode_String(deserializer); var var_waiting = sse_decode_String(deserializer);
var var_enterPin = sse_decode_String(deserializer); var var_enterPin = sse_decode_String(deserializer);
var var_invalidPin = sse_decode_String(deserializer); var var_invalidPin = sse_decode_String(deserializer);
var var_tooManyAttempts = sse_decode_String(deserializer); var var_tooManyAttempts = sse_decode_String(deserializer);
var var_rejected = sse_decode_String(deserializer); var var_rejected = sse_decode_String(deserializer);
var var_uploadRejected = sse_decode_String(deserializer);
var var_busy = sse_decode_String(deserializer);
var var_files = sse_decode_String(deserializer); var var_files = sse_decode_String(deserializer);
var var_fileName = sse_decode_String(deserializer); var var_fileName = sse_decode_String(deserializer);
var var_size = sse_decode_String(deserializer); var var_size = sse_decode_String(deserializer);
return WebSendI18n( return WebI18n(
waiting: var_waiting, waiting: var_waiting,
enterPin: var_enterPin, enterPin: var_enterPin,
invalidPin: var_invalidPin, invalidPin: var_invalidPin,
tooManyAttempts: var_tooManyAttempts, tooManyAttempts: var_tooManyAttempts,
rejected: var_rejected, rejected: var_rejected,
uploadRejected: var_uploadRejected,
busy: var_busy,
files: var_files, files: var_files,
fileName: var_fileName, fileName: var_fileName,
size: var_size, size: var_size,
); );
} }
@protected
WebParams sse_decode_web_params(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
var var_send = sse_decode_opt_box_autoadd_web_send_params(deserializer);
var var_upload = sse_decode_bool(deserializer);
var var_i18N = sse_decode_web_i_18_n(deserializer);
return WebParams(send: var_send, upload: var_upload, i18N: var_i18N);
}
@protected @protected
WebSendParams sse_decode_web_send_params(SseDeserializer deserializer) { WebSendParams sse_decode_web_send_params(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
var var_files = sse_decode_Map_String_file_dto_None(deserializer); var var_files = sse_decode_Map_String_file_dto_None(deserializer);
var var_pin = sse_decode_opt_String(deserializer); var var_pin = sse_decode_opt_String(deserializer);
var var_i18N = sse_decode_web_send_i_18_n(deserializer); return WebSendParams(files: var_files, pin: var_pin);
return WebSendParams(files: var_files, pin: var_pin, i18N: var_i18N);
} }
@protected @protected
@@ -5253,6 +5307,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_u_32(self, serializer); sse_encode_u_32(self, serializer);
} }
@protected
void sse_encode_box_autoadd_web_params(WebParams self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_web_params(self, serializer);
}
@protected @protected
void sse_encode_box_autoadd_web_send_params(WebSendParams self, SseSerializer serializer) { void sse_encode_box_autoadd_web_send_params(WebSendParams self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
@@ -5530,6 +5590,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
} }
} }
@protected
void sse_encode_opt_box_autoadd_web_params(WebParams? self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_bool(self != null, serializer);
if (self != null) {
sse_encode_box_autoadd_web_params(self, serializer);
}
}
@protected @protected
void sse_encode_opt_box_autoadd_web_send_params(WebSendParams? self, SseSerializer serializer) { void sse_encode_opt_box_autoadd_web_send_params(WebSendParams? self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
@@ -5892,24 +5962,33 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
} }
@protected @protected
void sse_encode_web_send_i_18_n(WebSendI18n self, SseSerializer serializer) { void sse_encode_web_i_18_n(WebI18n self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_String(self.waiting, serializer); sse_encode_String(self.waiting, serializer);
sse_encode_String(self.enterPin, serializer); sse_encode_String(self.enterPin, serializer);
sse_encode_String(self.invalidPin, serializer); sse_encode_String(self.invalidPin, serializer);
sse_encode_String(self.tooManyAttempts, serializer); sse_encode_String(self.tooManyAttempts, serializer);
sse_encode_String(self.rejected, serializer); sse_encode_String(self.rejected, serializer);
sse_encode_String(self.uploadRejected, serializer);
sse_encode_String(self.busy, serializer);
sse_encode_String(self.files, serializer); sse_encode_String(self.files, serializer);
sse_encode_String(self.fileName, serializer); sse_encode_String(self.fileName, serializer);
sse_encode_String(self.size, serializer); sse_encode_String(self.size, serializer);
} }
@protected
void sse_encode_web_params(WebParams self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_opt_box_autoadd_web_send_params(self.send, serializer);
sse_encode_bool(self.upload, serializer);
sse_encode_web_i_18_n(self.i18N, serializer);
}
@protected @protected
void sse_encode_web_send_params(WebSendParams self, SseSerializer serializer) { void sse_encode_web_send_params(WebSendParams self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_Map_String_file_dto_None(self.files, serializer); sse_encode_Map_String_file_dto_None(self.files, serializer);
sse_encode_opt_String(self.pin, serializer); sse_encode_opt_String(self.pin, serializer);
sse_encode_web_send_i_18_n(self.i18N, serializer);
} }
@protected @protected
@@ -6104,6 +6183,9 @@ class RsDiscoveryImpl extends RustOpaque implements RsDiscovery {
/// Emits a [RsStoredDevice] for every device confirmation until the /// Emits a [RsStoredDevice] for every device confirmation until the
/// discovery is stopped. Can only be listened to once. /// discovery is stopped. Can only be listened to once.
///
/// Also returns when the Dart side of the stream is gone (e.g. after a
/// hot restart), so this call does not keep the discovery alive forever.
Stream<RsStoredDevice> listen() => RustLib.instance.api.crateApiDiscoveryRsDiscoveryListen( Stream<RsStoredDevice> listen() => RustLib.instance.api.crateApiDiscoveryRsDiscoveryListen(
that: this, that: this,
); );
@@ -6259,6 +6341,9 @@ class RsHttpServerImpl extends RustOpaque implements RsHttpServer {
/// ///
/// The v2 protocol, the web send (download API), and the internal endpoint /// The v2 protocol, the web send (download API), and the internal endpoint
/// events are all emitted on the same stream. /// events are all emitted on the same stream.
///
/// Also returns when the Dart side of the stream is gone (e.g. after a
/// hot restart), so this call does not keep the server alive forever.
Stream<RsServerEvent> listen() => RustLib.instance.api.crateApiServerRsHttpServerListen( Stream<RsServerEvent> listen() => RustLib.instance.api.crateApiServerRsHttpServerListen(
that: this, that: this,
); );
@@ -279,6 +279,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
int dco_decode_box_autoadd_u_32(dynamic raw); int dco_decode_box_autoadd_u_32(dynamic raw);
@protected
WebParams dco_decode_box_autoadd_web_params(dynamic raw);
@protected @protected
WebSendParams dco_decode_box_autoadd_web_send_params(dynamic raw); WebSendParams dco_decode_box_autoadd_web_send_params(dynamic raw);
@@ -376,6 +379,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
int? dco_decode_opt_box_autoadd_u_32(dynamic raw); int? dco_decode_opt_box_autoadd_u_32(dynamic raw);
@protected
WebParams? dco_decode_opt_box_autoadd_web_params(dynamic raw);
@protected @protected
WebSendParams? dco_decode_opt_box_autoadd_web_send_params(dynamic raw); WebSendParams? dco_decode_opt_box_autoadd_web_send_params(dynamic raw);
@@ -482,7 +488,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
BigInt dco_decode_usize(dynamic raw); BigInt dco_decode_usize(dynamic raw);
@protected @protected
WebSendI18n dco_decode_web_send_i_18_n(dynamic raw); WebI18n dco_decode_web_i_18_n(dynamic raw);
@protected
WebParams dco_decode_web_params(dynamic raw);
@protected @protected
WebSendParams dco_decode_web_send_params(dynamic raw); WebSendParams dco_decode_web_send_params(dynamic raw);
@@ -728,6 +737,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
int sse_decode_box_autoadd_u_32(SseDeserializer deserializer); int sse_decode_box_autoadd_u_32(SseDeserializer deserializer);
@protected
WebParams sse_decode_box_autoadd_web_params(SseDeserializer deserializer);
@protected @protected
WebSendParams sse_decode_box_autoadd_web_send_params(SseDeserializer deserializer); WebSendParams sse_decode_box_autoadd_web_send_params(SseDeserializer deserializer);
@@ -827,6 +839,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
int? sse_decode_opt_box_autoadd_u_32(SseDeserializer deserializer); int? sse_decode_opt_box_autoadd_u_32(SseDeserializer deserializer);
@protected
WebParams? sse_decode_opt_box_autoadd_web_params(SseDeserializer deserializer);
@protected @protected
WebSendParams? sse_decode_opt_box_autoadd_web_send_params(SseDeserializer deserializer); WebSendParams? sse_decode_opt_box_autoadd_web_send_params(SseDeserializer deserializer);
@@ -933,7 +948,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
BigInt sse_decode_usize(SseDeserializer deserializer); BigInt sse_decode_usize(SseDeserializer deserializer);
@protected @protected
WebSendI18n sse_decode_web_send_i_18_n(SseDeserializer deserializer); WebI18n sse_decode_web_i_18_n(SseDeserializer deserializer);
@protected
WebParams sse_decode_web_params(SseDeserializer deserializer);
@protected @protected
WebSendParams sse_decode_web_send_params(SseDeserializer deserializer); WebSendParams sse_decode_web_send_params(SseDeserializer deserializer);
@@ -1224,6 +1242,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer); void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_web_params(WebParams self, SseSerializer serializer);
@protected @protected
void sse_encode_box_autoadd_web_send_params(WebSendParams self, SseSerializer serializer); void sse_encode_box_autoadd_web_send_params(WebSendParams self, SseSerializer serializer);
@@ -1323,6 +1344,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void sse_encode_opt_box_autoadd_u_32(int? self, SseSerializer serializer); void sse_encode_opt_box_autoadd_u_32(int? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_web_params(WebParams? self, SseSerializer serializer);
@protected @protected
void sse_encode_opt_box_autoadd_web_send_params(WebSendParams? self, SseSerializer serializer); void sse_encode_opt_box_autoadd_web_send_params(WebSendParams? self, SseSerializer serializer);
@@ -1430,7 +1454,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
void sse_encode_usize(BigInt self, SseSerializer serializer); void sse_encode_usize(BigInt self, SseSerializer serializer);
@protected @protected
void sse_encode_web_send_i_18_n(WebSendI18n self, SseSerializer serializer); void sse_encode_web_i_18_n(WebI18n self, SseSerializer serializer);
@protected
void sse_encode_web_params(WebParams self, SseSerializer serializer);
@protected @protected
void sse_encode_web_send_params(WebSendParams self, SseSerializer serializer); void sse_encode_web_send_params(WebSendParams self, SseSerializer serializer);
@@ -281,6 +281,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
int dco_decode_box_autoadd_u_32(dynamic raw); int dco_decode_box_autoadd_u_32(dynamic raw);
@protected
WebParams dco_decode_box_autoadd_web_params(dynamic raw);
@protected @protected
WebSendParams dco_decode_box_autoadd_web_send_params(dynamic raw); WebSendParams dco_decode_box_autoadd_web_send_params(dynamic raw);
@@ -378,6 +381,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
int? dco_decode_opt_box_autoadd_u_32(dynamic raw); int? dco_decode_opt_box_autoadd_u_32(dynamic raw);
@protected
WebParams? dco_decode_opt_box_autoadd_web_params(dynamic raw);
@protected @protected
WebSendParams? dco_decode_opt_box_autoadd_web_send_params(dynamic raw); WebSendParams? dco_decode_opt_box_autoadd_web_send_params(dynamic raw);
@@ -484,7 +490,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
BigInt dco_decode_usize(dynamic raw); BigInt dco_decode_usize(dynamic raw);
@protected @protected
WebSendI18n dco_decode_web_send_i_18_n(dynamic raw); WebI18n dco_decode_web_i_18_n(dynamic raw);
@protected
WebParams dco_decode_web_params(dynamic raw);
@protected @protected
WebSendParams dco_decode_web_send_params(dynamic raw); WebSendParams dco_decode_web_send_params(dynamic raw);
@@ -730,6 +739,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
int sse_decode_box_autoadd_u_32(SseDeserializer deserializer); int sse_decode_box_autoadd_u_32(SseDeserializer deserializer);
@protected
WebParams sse_decode_box_autoadd_web_params(SseDeserializer deserializer);
@protected @protected
WebSendParams sse_decode_box_autoadd_web_send_params(SseDeserializer deserializer); WebSendParams sse_decode_box_autoadd_web_send_params(SseDeserializer deserializer);
@@ -829,6 +841,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
int? sse_decode_opt_box_autoadd_u_32(SseDeserializer deserializer); int? sse_decode_opt_box_autoadd_u_32(SseDeserializer deserializer);
@protected
WebParams? sse_decode_opt_box_autoadd_web_params(SseDeserializer deserializer);
@protected @protected
WebSendParams? sse_decode_opt_box_autoadd_web_send_params(SseDeserializer deserializer); WebSendParams? sse_decode_opt_box_autoadd_web_send_params(SseDeserializer deserializer);
@@ -935,7 +950,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
BigInt sse_decode_usize(SseDeserializer deserializer); BigInt sse_decode_usize(SseDeserializer deserializer);
@protected @protected
WebSendI18n sse_decode_web_send_i_18_n(SseDeserializer deserializer); WebI18n sse_decode_web_i_18_n(SseDeserializer deserializer);
@protected
WebParams sse_decode_web_params(SseDeserializer deserializer);
@protected @protected
WebSendParams sse_decode_web_send_params(SseDeserializer deserializer); WebSendParams sse_decode_web_send_params(SseDeserializer deserializer);
@@ -1226,6 +1244,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer); void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_web_params(WebParams self, SseSerializer serializer);
@protected @protected
void sse_encode_box_autoadd_web_send_params(WebSendParams self, SseSerializer serializer); void sse_encode_box_autoadd_web_send_params(WebSendParams self, SseSerializer serializer);
@@ -1325,6 +1346,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void sse_encode_opt_box_autoadd_u_32(int? self, SseSerializer serializer); void sse_encode_opt_box_autoadd_u_32(int? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_web_params(WebParams? self, SseSerializer serializer);
@protected @protected
void sse_encode_opt_box_autoadd_web_send_params(WebSendParams? self, SseSerializer serializer); void sse_encode_opt_box_autoadd_web_send_params(WebSendParams? self, SseSerializer serializer);
@@ -1432,7 +1456,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
void sse_encode_usize(BigInt self, SseSerializer serializer); void sse_encode_usize(BigInt self, SseSerializer serializer);
@protected @protected
void sse_encode_web_send_i_18_n(WebSendI18n self, SseSerializer serializer); void sse_encode_web_i_18_n(WebI18n self, SseSerializer serializer);
@protected
void sse_encode_web_params(WebParams self, SseSerializer serializer);
@protected @protected
void sse_encode_web_send_params(WebSendParams self, SseSerializer serializer); void sse_encode_web_send_params(WebSendParams self, SseSerializer serializer);
@@ -30,9 +30,9 @@ class HttpServerStartTask implements BaseHttpServerTask {
/// Optional PIN that senders must provide to start an upload session. /// Optional PIN that senders must provide to start an upload session.
final String? pin; final String? pin;
/// Enables web send (download API) so web browsers can download the offered files. /// Serves the web pages: the download page (web send) and/or the upload page.
/// `null` disables web send. /// `null` disables the web pages.
final WebSendParams? webSend; final WebParams? web;
/// Enables the internal `show` endpoint, guarded by this token, that lets another /// Enables the internal `show` endpoint, guarded by this token, that lets another
/// application instance request this one to show itself. `null` disables it. /// application instance request this one to show itself. `null` disables it.
@@ -40,7 +40,7 @@ class HttpServerStartTask implements BaseHttpServerTask {
HttpServerStartTask({ HttpServerStartTask({
required this.pin, required this.pin,
required this.webSend, required this.web,
required this.showToken, required this.showToken,
}); });
} }
@@ -398,7 +398,7 @@ Future<void> setupHttpServerIsolate(
deviceType: syncState.deviceInfo.deviceType.toRust(), deviceType: syncState.deviceInfo.deviceType.toRust(),
fingerprint: syncState.securityContext.certificateHash, fingerprint: syncState.securityContext.certificateHash,
pin: startTask.pin, pin: startTask.pin,
webSend: startTask.webSend, web: startTask.web,
showToken: startTask.showToken, showToken: startTask.showToken,
); );
} catch (e) { } catch (e) {
@@ -1,7 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:localsend_isolates/model/device.dart'; import 'package:localsend_isolates/model/device.dart';
import 'package:localsend_isolates/rust/api/server.dart' show WebSendParams; import 'package:localsend_isolates/rust/api/server.dart' show WebParams;
import 'package:localsend_isolates/src/isolate/child/discovery_isolate.dart'; import 'package:localsend_isolates/src/isolate/child/discovery_isolate.dart';
import 'package:localsend_isolates/src/isolate/child/server_isolate.dart'; import 'package:localsend_isolates/src/isolate/child/server_isolate.dart';
import 'package:localsend_isolates/src/isolate/child/upload_isolate.dart'; import 'package:localsend_isolates/src/isolate/child/upload_isolate.dart';
@@ -256,9 +256,9 @@ class IsolateHttpUploadCancelAction extends ReduxAction<IsolateController, Paren
class IsolateHttpServerStartAction extends ReduxActionWithResult<IsolateController, ParentIsolateState, Stream<HttpServerEvent>> { class IsolateHttpServerStartAction extends ReduxActionWithResult<IsolateController, ParentIsolateState, Stream<HttpServerEvent>> {
final String? pin; final String? pin;
/// Enables web send (download API) so web browsers can download the offered files. /// Serves the web pages: the download page (web send) and/or the upload page.
/// `null` disables web send. /// `null` disables the web pages.
final WebSendParams? webSend; final WebParams? web;
/// Enables the internal `show` endpoint, guarded by this token, that lets another /// Enables the internal `show` endpoint, guarded by this token, that lets another
/// application instance request this one to show itself. `null` disables it. /// application instance request this one to show itself. `null` disables it.
@@ -266,7 +266,7 @@ class IsolateHttpServerStartAction extends ReduxActionWithResult<IsolateControll
IsolateHttpServerStartAction({ IsolateHttpServerStartAction({
required this.pin, required this.pin,
required this.webSend, required this.web,
required this.showToken, required this.showToken,
}); });
@@ -282,7 +282,7 @@ class IsolateHttpServerStartAction extends ReduxActionWithResult<IsolateControll
connection.sendWrappedTaskAndListenStream( connection.sendWrappedTaskAndListenStream(
task: HttpServerStartTask( task: HttpServerStartTask(
pin: pin, pin: pin,
webSend: webSend, web: web,
showToken: showToken, showToken: showToken,
), ),
), ),
@@ -22,7 +22,7 @@ class HttpServerService {
required DeviceType? deviceType, required DeviceType? deviceType,
required String fingerprint, required String fingerprint,
required String? pin, required String? pin,
required WebSendParams? webSend, required WebParams? web,
required String? showToken, required String? showToken,
}) async { }) async {
if (_server != null) { if (_server != null) {
@@ -38,7 +38,7 @@ class HttpServerService {
deviceType: deviceType, deviceType: deviceType,
fingerprint: fingerprint, fingerprint: fingerprint,
pin: pin, pin: pin,
webSend: webSend, web: web,
showToken: showToken, showToken: showToken,
); );
_server = server; _server = server;
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies:
mime: 2.0.0 mime: 2.0.0
path: 1.9.1 path: 1.9.1
pool: 1.5.2 pool: 1.5.2
refena_flutter: 3.2.1 refena_flutter: ^3.4.0
rust_lib_localsend_app: rust_lib_localsend_app:
path: rust_builder path: rust_builder
typed_isolates: typed_isolates:
@@ -7,8 +7,8 @@ use localsend::http::server::common::save::FileUploadTarget;
use localsend::http::server::internal::{InternalConfig, InternalEvent}; use localsend::http::server::internal::{InternalConfig, InternalEvent};
pub use localsend::http::server::v2::SessionEndReasonV2; pub use localsend::http::server::v2::SessionEndReasonV2;
use localsend::http::server::v2::{PrepareUploadDecisionV2, ServerEventV2}; use localsend::http::server::v2::{PrepareUploadDecisionV2, ServerEventV2};
pub use localsend::http::server::web::WebSendI18n; pub use localsend::http::server::web::WebI18n;
use localsend::http::server::web::{WebSendConfig, WebSendEvent}; use localsend::http::server::web::{WebConfig, WebSendConfig, WebSendEvent};
use localsend::http::state::ClientInfo; use localsend::http::state::ClientInfo;
use localsend::model::discovery::DeviceType; use localsend::model::discovery::DeviceType;
use localsend::model::discovery::ProtocolType; use localsend::model::discovery::ProtocolType;
@@ -134,10 +134,25 @@ impl ServerInstance {
/// binding again. /// binding again.
static RUNNING_SERVER: Mutex<Option<Arc<ServerInstance>>> = Mutex::const_new(None); static RUNNING_SERVER: Mutex<Option<Arc<ServerInstance>>> = Mutex::const_new(None);
/// Configuration for the web pages served to browsers. When omitted, the web
/// pages respond with 403 and only the v2 endpoints run.
pub struct WebParams {
/// Enables web send (the download page): files offered for download by web
/// browsers. `null` disables the download page and the download API.
pub send: Option<WebSendParams>,
/// Serves the upload page so web browsers can upload files via the v2
/// `prepare-upload`/`upload` endpoints. Ignored when [WebParams::send] is
/// set: the download page takes precedence at `/`.
pub upload: bool,
/// Translations for the web pages, served via `/i18n.json`.
pub i18n: WebI18n,
}
/// Configuration for web send: files offered for download by web browsers. /// Configuration for web send: files offered for download by web browsers.
/// ///
/// Web send can be enabled independently of the v2 protocol endpoints. When /// Web send can be enabled independently of the v2 protocol endpoints.
/// omitted, the download API responds with 403 and only the v2 endpoints run.
pub struct WebSendParams { pub struct WebSendParams {
/// The metadata of the files offered for download, mapped by file ID. /// The metadata of the files offered for download, mapped by file ID.
/// The content is requested per download via [RsServerEvent::WebFileDownload]. /// The content is requested per download via [RsServerEvent::WebFileDownload].
@@ -145,16 +160,14 @@ pub struct WebSendParams {
/// Optional PIN that web clients must provide via the `pin` query parameter. /// Optional PIN that web clients must provide via the `pin` query parameter.
pub pin: Option<String>, pub pin: Option<String>,
/// Translations for the web page, served via `/i18n.json`.
pub i18n: WebSendI18n,
} }
/// Starts the HTTP server on the given port (IPv4 and IPv6). /// Starts the HTTP server on the given port (IPv4 and IPv6).
/// The server runs until [RsHttpServer::stop] is called. /// The server runs until [RsHttpServer::stop] is called.
/// ///
/// Passing [web_send] additionally enables the web send (download API) so that /// Passing [web] additionally serves the web pages: the download page when
/// web browsers can download the offered files. /// [WebParams::send] is set (so web browsers can download the offered files)
/// or the upload page when [WebParams::upload] is enabled.
/// ///
/// Passing [show_token] enables the internal `show` endpoint that lets another /// Passing [show_token] enables the internal `show` endpoint that lets another
/// application instance request this one to show itself (emitted as /// application instance request this one to show itself (emitted as
@@ -170,7 +183,7 @@ pub async fn start_server(
device_type: Option<DeviceType>, device_type: Option<DeviceType>,
fingerprint: String, fingerprint: String,
pin: Option<String>, pin: Option<String>,
web_send: Option<WebSendParams>, web: Option<WebParams>,
show_token: Option<String>, show_token: Option<String>,
) -> anyhow::Result<RsHttpServer> { ) -> anyhow::Result<RsHttpServer> {
// Stop a server left over from before a hot restart (its Dart owner died // Stop a server left over from before a hot restart (its Dart owner died
@@ -183,19 +196,29 @@ pub async fn start_server(
let (event_tx, event_rx) = mpsc::channel::<ServerEventV2>(16); let (event_tx, event_rx) = mpsc::channel::<ServerEventV2>(16);
let (stop_tx, stop_rx) = oneshot::channel::<()>(); let (stop_tx, stop_rx) = oneshot::channel::<()>();
let (web_send_config, web_event_rx) = match web_send { let (web_config, web_event_rx) = match web {
Some(web_send) => { Some(web) => {
let (send_config, web_event_rx) = match web.send {
Some(send) => {
let (web_event_tx, web_event_rx) = mpsc::channel::<WebSendEvent>(16); let (web_event_tx, web_event_rx) = mpsc::channel::<WebSendEvent>(16);
let config = WebSendConfig { let config = WebSendConfig {
files: web_send.files, files: send.files,
pin: web_send.pin, pin: send.pin,
i18n: web_send.i18n,
event_tx: web_event_tx, event_tx: web_event_tx,
}; };
(Some(config), Some(web_event_rx)) (Some(config), Some(web_event_rx))
} }
None => (None, None), None => (None, None),
}; };
let config = WebConfig {
send: send_config,
upload: web.upload,
i18n: web.i18n,
};
(Some(config), web_event_rx)
}
None => (None, None),
};
let (internal_config, internal_event_rx) = match show_token { let (internal_config, internal_event_rx) = match show_token {
Some(show_token) => { Some(show_token) => {
@@ -221,7 +244,7 @@ pub async fn start_server(
}, },
internal_config, internal_config,
Some(ServerConfigV2 { pin, event_tx }), Some(ServerConfigV2 { pin, event_tx }),
web_send_config, web_config,
stop_rx, stop_rx,
) )
.await?; .await?;
@@ -698,13 +721,15 @@ fn resolve_file_content(
} }
} }
#[frb(mirror(WebSendI18n))] #[frb(mirror(WebI18n))]
pub struct _WebSendI18n { pub struct _WebI18n {
pub waiting: String, pub waiting: String,
pub enter_pin: String, pub enter_pin: String,
pub invalid_pin: String, pub invalid_pin: String,
pub too_many_attempts: String, pub too_many_attempts: String,
pub rejected: String, pub rejected: String,
pub upload_rejected: String,
pub busy: String,
pub files: String, pub files: String,
pub file_name: String, pub file_name: String,
pub size: String, pub size: String,
@@ -3201,8 +3201,7 @@ fn wire__crate__api__server__start_server_impl(
<Option<crate::api::model::DeviceType>>::sse_decode(&mut deserializer); <Option<crate::api::model::DeviceType>>::sse_decode(&mut deserializer);
let api_fingerprint = <String>::sse_decode(&mut deserializer); let api_fingerprint = <String>::sse_decode(&mut deserializer);
let api_pin = <Option<String>>::sse_decode(&mut deserializer); let api_pin = <Option<String>>::sse_decode(&mut deserializer);
let api_web_send = let api_web = <Option<crate::api::server::WebParams>>::sse_decode(&mut deserializer);
<Option<crate::api::server::WebSendParams>>::sse_decode(&mut deserializer);
let api_show_token = <Option<String>>::sse_decode(&mut deserializer); let api_show_token = <Option<String>>::sse_decode(&mut deserializer);
deserializer.end(); deserializer.end();
move |context| async move { move |context| async move {
@@ -3217,7 +3216,7 @@ fn wire__crate__api__server__start_server_impl(
api_device_type, api_device_type,
api_fingerprint, api_fingerprint,
api_pin, api_pin,
api_web_send, api_web,
api_show_token, api_show_token,
) )
.await?; .await?;
@@ -3383,15 +3382,17 @@ const _: fn() = || {
let _: String = TlsConfig.private_key; let _: String = TlsConfig.private_key;
} }
{ {
let WebSendI18n = None::<crate::api::server::WebSendI18n>.unwrap(); let WebI18n = None::<crate::api::server::WebI18n>.unwrap();
let _: String = WebSendI18n.waiting; let _: String = WebI18n.waiting;
let _: String = WebSendI18n.enter_pin; let _: String = WebI18n.enter_pin;
let _: String = WebSendI18n.invalid_pin; let _: String = WebI18n.invalid_pin;
let _: String = WebSendI18n.too_many_attempts; let _: String = WebI18n.too_many_attempts;
let _: String = WebSendI18n.rejected; let _: String = WebI18n.rejected;
let _: String = WebSendI18n.files; let _: String = WebI18n.upload_rejected;
let _: String = WebSendI18n.file_name; let _: String = WebI18n.busy;
let _: String = WebSendI18n.size; let _: String = WebI18n.files;
let _: String = WebI18n.file_name;
let _: String = WebI18n.size;
} }
match None::<crate::api::webrtc::WsServerMessage>.unwrap() { match None::<crate::api::webrtc::WsServerMessage>.unwrap() {
crate::api::webrtc::WsServerMessage::Hello { client, peers } => { crate::api::webrtc::WsServerMessage::Hello { client, peers } => {
@@ -4234,6 +4235,17 @@ impl SseDecode for Option<u32> {
} }
} }
impl SseDecode for Option<crate::api::server::WebParams> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
if (<bool>::sse_decode(deserializer)) {
return Some(<crate::api::server::WebParams>::sse_decode(deserializer));
} else {
return None;
}
}
}
impl SseDecode for Option<crate::api::server::WebSendParams> { impl SseDecode for Option<crate::api::server::WebSendParams> {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -4812,7 +4824,7 @@ impl SseDecode for usize {
} }
} }
impl SseDecode for crate::api::server::WebSendI18n { impl SseDecode for crate::api::server::WebI18n {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_waiting = <String>::sse_decode(deserializer); let mut var_waiting = <String>::sse_decode(deserializer);
@@ -4820,15 +4832,19 @@ impl SseDecode for crate::api::server::WebSendI18n {
let mut var_invalidPin = <String>::sse_decode(deserializer); let mut var_invalidPin = <String>::sse_decode(deserializer);
let mut var_tooManyAttempts = <String>::sse_decode(deserializer); let mut var_tooManyAttempts = <String>::sse_decode(deserializer);
let mut var_rejected = <String>::sse_decode(deserializer); let mut var_rejected = <String>::sse_decode(deserializer);
let mut var_uploadRejected = <String>::sse_decode(deserializer);
let mut var_busy = <String>::sse_decode(deserializer);
let mut var_files = <String>::sse_decode(deserializer); let mut var_files = <String>::sse_decode(deserializer);
let mut var_fileName = <String>::sse_decode(deserializer); let mut var_fileName = <String>::sse_decode(deserializer);
let mut var_size = <String>::sse_decode(deserializer); let mut var_size = <String>::sse_decode(deserializer);
return crate::api::server::WebSendI18n { return crate::api::server::WebI18n {
waiting: var_waiting, waiting: var_waiting,
enter_pin: var_enterPin, enter_pin: var_enterPin,
invalid_pin: var_invalidPin, invalid_pin: var_invalidPin,
too_many_attempts: var_tooManyAttempts, too_many_attempts: var_tooManyAttempts,
rejected: var_rejected, rejected: var_rejected,
upload_rejected: var_uploadRejected,
busy: var_busy,
files: var_files, files: var_files,
file_name: var_fileName, file_name: var_fileName,
size: var_size, size: var_size,
@@ -4836,6 +4852,20 @@ impl SseDecode for crate::api::server::WebSendI18n {
} }
} }
impl SseDecode for crate::api::server::WebParams {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_send = <Option<crate::api::server::WebSendParams>>::sse_decode(deserializer);
let mut var_upload = <bool>::sse_decode(deserializer);
let mut var_i18N = <crate::api::server::WebI18n>::sse_decode(deserializer);
return crate::api::server::WebParams {
send: var_send,
upload: var_upload,
i18n: var_i18N,
};
}
}
impl SseDecode for crate::api::server::WebSendParams { impl SseDecode for crate::api::server::WebSendParams {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -4844,11 +4874,9 @@ impl SseDecode for crate::api::server::WebSendParams {
deserializer, deserializer,
); );
let mut var_pin = <Option<String>>::sse_decode(deserializer); let mut var_pin = <Option<String>>::sse_decode(deserializer);
let mut var_i18N = <crate::api::server::WebSendI18n>::sse_decode(deserializer);
return crate::api::server::WebSendParams { return crate::api::server::WebSendParams {
files: var_files, files: var_files,
pin: var_pin, pin: var_pin,
i18n: var_i18N,
}; };
} }
} }
@@ -6139,7 +6167,7 @@ impl flutter_rust_bridge::IntoIntoDart<FrbWrapper<crate::api::server::TlsConfig>
} }
} }
// Codec=Dco (DartCObject based), see doc to use other codecs // Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for FrbWrapper<crate::api::server::WebSendI18n> { impl flutter_rust_bridge::IntoDart for FrbWrapper<crate::api::server::WebI18n> {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[ [
self.0.waiting.into_into_dart().into_dart(), self.0.waiting.into_into_dart().into_dart(),
@@ -6147,6 +6175,8 @@ impl flutter_rust_bridge::IntoDart for FrbWrapper<crate::api::server::WebSendI18
self.0.invalid_pin.into_into_dart().into_dart(), self.0.invalid_pin.into_into_dart().into_dart(),
self.0.too_many_attempts.into_into_dart().into_dart(), self.0.too_many_attempts.into_into_dart().into_dart(),
self.0.rejected.into_into_dart().into_dart(), self.0.rejected.into_into_dart().into_dart(),
self.0.upload_rejected.into_into_dart().into_dart(),
self.0.busy.into_into_dart().into_dart(),
self.0.files.into_into_dart().into_dart(), self.0.files.into_into_dart().into_dart(),
self.0.file_name.into_into_dart().into_dart(), self.0.file_name.into_into_dart().into_dart(),
self.0.size.into_into_dart().into_dart(), self.0.size.into_into_dart().into_dart(),
@@ -6155,23 +6185,41 @@ impl flutter_rust_bridge::IntoDart for FrbWrapper<crate::api::server::WebSendI18
} }
} }
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for FrbWrapper<crate::api::server::WebSendI18n> for FrbWrapper<crate::api::server::WebI18n>
{ {
} }
impl flutter_rust_bridge::IntoIntoDart<FrbWrapper<crate::api::server::WebSendI18n>> impl flutter_rust_bridge::IntoIntoDart<FrbWrapper<crate::api::server::WebI18n>>
for crate::api::server::WebSendI18n for crate::api::server::WebI18n
{ {
fn into_into_dart(self) -> FrbWrapper<crate::api::server::WebSendI18n> { fn into_into_dart(self) -> FrbWrapper<crate::api::server::WebI18n> {
self.into() self.into()
} }
} }
// Codec=Dco (DartCObject based), see doc to use other codecs // Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::server::WebParams {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.send.into_into_dart().into_dart(),
self.upload.into_into_dart().into_dart(),
self.i18n.into_into_dart().into_dart(),
]
.into_dart()
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::server::WebParams {}
impl flutter_rust_bridge::IntoIntoDart<crate::api::server::WebParams>
for crate::api::server::WebParams
{
fn into_into_dart(self) -> crate::api::server::WebParams {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::server::WebSendParams { impl flutter_rust_bridge::IntoDart for crate::api::server::WebSendParams {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[ [
self.files.into_into_dart().into_dart(), self.files.into_into_dart().into_dart(),
self.pin.into_into_dart().into_dart(), self.pin.into_into_dart().into_dart(),
self.i18n.into_into_dart().into_dart(),
] ]
.into_dart() .into_dart()
} }
@@ -6914,6 +6962,16 @@ impl SseEncode for Option<u32> {
} }
} }
impl SseEncode for Option<crate::api::server::WebParams> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<bool>::sse_encode(self.is_some(), serializer);
if let Some(value) = self {
<crate::api::server::WebParams>::sse_encode(value, serializer);
}
}
}
impl SseEncode for Option<crate::api::server::WebSendParams> { impl SseEncode for Option<crate::api::server::WebSendParams> {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -7377,7 +7435,7 @@ impl SseEncode for usize {
} }
} }
impl SseEncode for crate::api::server::WebSendI18n { impl SseEncode for crate::api::server::WebI18n {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<String>::sse_encode(self.waiting, serializer); <String>::sse_encode(self.waiting, serializer);
@@ -7385,12 +7443,23 @@ impl SseEncode for crate::api::server::WebSendI18n {
<String>::sse_encode(self.invalid_pin, serializer); <String>::sse_encode(self.invalid_pin, serializer);
<String>::sse_encode(self.too_many_attempts, serializer); <String>::sse_encode(self.too_many_attempts, serializer);
<String>::sse_encode(self.rejected, serializer); <String>::sse_encode(self.rejected, serializer);
<String>::sse_encode(self.upload_rejected, serializer);
<String>::sse_encode(self.busy, serializer);
<String>::sse_encode(self.files, serializer); <String>::sse_encode(self.files, serializer);
<String>::sse_encode(self.file_name, serializer); <String>::sse_encode(self.file_name, serializer);
<String>::sse_encode(self.size, serializer); <String>::sse_encode(self.size, serializer);
} }
} }
impl SseEncode for crate::api::server::WebParams {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<Option<crate::api::server::WebSendParams>>::sse_encode(self.send, serializer);
<bool>::sse_encode(self.upload, serializer);
<crate::api::server::WebI18n>::sse_encode(self.i18n, serializer);
}
}
impl SseEncode for crate::api::server::WebSendParams { impl SseEncode for crate::api::server::WebSendParams {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -7398,7 +7467,6 @@ impl SseEncode for crate::api::server::WebSendParams {
self.files, serializer, self.files, serializer,
); );
<Option<String>>::sse_encode(self.pin, serializer); <Option<String>>::sse_encode(self.pin, serializer);
<crate::api::server::WebSendI18n>::sse_encode(self.i18n, serializer);
} }
} }