From 73ff2fe6e2de9fdb66d95c7c3a374db38befa4cc Mon Sep 17 00:00:00 2001 From: Tien Do Nam Date: Mon, 3 Aug 2026 04:33:21 +0200 Subject: [PATCH] feat: add web receive --- AGENTS.md | 2 +- app/assets/i18n/en.json | 3 + app/lib/gen/strings.g.dart | 2 +- app/lib/gen/strings_en.g.dart | 13 + app/lib/model/state/server/server_state.dart | 6 +- .../state/server/server_state.mapper.dart | 11 + app/lib/model/state/settings_state.dart | 2 + .../model/state/settings_state.mapper.dart | 16 ++ app/lib/pages/progress_page.dart | 9 +- app/lib/pages/tabs/receive_tab.dart | 61 +---- app/lib/pages/web_receive_page.dart | 252 ++++++++++++++++++ .../server/controller/receive_controller.dart | 4 + .../network/server/server_provider.dart | 36 ++- app/lib/provider/persistence_provider.dart | 9 + app/lib/provider/settings_provider.dart | 9 + app/pubspec.lock | 8 +- app/pubspec.yaml | 2 +- app/test/mocks.mocks.dart | 31 +++ .../assets/web/{index.html => download.html} | 2 +- .../core/assets/web/{main.js => download.js} | 0 packages/core/assets/web/upload.html | 232 ++++++++++++++++ .../server/common/client_cert_verifier.rs | 10 +- packages/core/src/http/server/mod.rs | 51 +++- packages/core/src/http/server/web.rs | 63 +++-- packages/core/tests/v2_tls_pinning.rs | 73 ++++- packages/core/tests/v2_web_send.rs | 78 +++++- .../lib/rust/api/discovery.dart | 6 +- .../lib/rust/api/server.dart | 71 +++-- .../lib/rust/frb_generated.dart | 123 +++++++-- .../lib/rust/frb_generated.io.dart | 33 ++- .../lib/rust/frb_generated.web.dart | 33 ++- .../lib/src/isolate/child/server_isolate.dart | 10 +- .../lib/src/isolate/parent/actions.dart | 12 +- .../lib/src/task/server/http_server.dart | 4 +- packages/localsend_isolates/pubspec.yaml | 2 +- .../localsend_isolates/rust/src/api/server.rs | 69 +++-- .../rust/src/frb_generated.rs | 116 ++++++-- 37 files changed, 1247 insertions(+), 217 deletions(-) create mode 100644 app/lib/pages/web_receive_page.dart rename packages/core/assets/web/{index.html => download.html} (97%) rename packages/core/assets/web/{main.js => download.js} (100%) create mode 100644 packages/core/assets/web/upload.html diff --git a/AGENTS.md b/AGENTS.md index 866f6957..0877417e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. -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. diff --git a/app/assets/i18n/en.json b/app/assets/i18n/en.json index afa25825..40551d1d 100644 --- a/app/assets/i18n/en.json +++ b/app/assets/i18n/en.json @@ -268,6 +268,9 @@ "encryptionHint": "LocalSend uses a self-signed certificate. You need to accept it in your browser.", "pendingRequests": "Pending requests: {n}" }, + "webReceivePage": { + "title": "Receive via link" + }, "aboutPage": { "title": "About LocalSend", "description": [ diff --git a/app/lib/gen/strings.g.dart b/app/lib/gen/strings.g.dart index 03310130..b6eaca2b 100644 --- a/app/lib/gen/strings.g.dart +++ b/app/lib/gen/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 55 -/// Strings: 18450 (335 per locale) +/// Strings: 18451 (335 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/app/lib/gen/strings_en.g.dart b/app/lib/gen/strings_en.g.dart index e6100f1b..c60e3926 100644 --- a/app/lib/gen/strings_en.g.dart +++ b/app/lib/gen/strings_en.g.dart @@ -63,6 +63,7 @@ class Translations with BaseTranslations { 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$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$donationPage$en donationPage = Translations$donationPage$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}'; } +// 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 class Translations$aboutPage$en { Translations$aboutPage$en.internal(this._root); diff --git a/app/lib/model/state/server/server_state.dart b/app/lib/model/state/server/server_state.dart index 9e1e433a..62ede98a 100644 --- a/app/lib/model/state/server/server_state.dart +++ b/app/lib/model/state/server/server_state.dart @@ -12,16 +12,20 @@ class ServerState with ServerStateMappable { final ReceiveSessionState? session; final WebSendState? webSendState; + /// Whether the upload page is served so web browsers can upload files. + final bool webUpload; + const ServerState({ required this.alias, required this.port, required this.https, required this.session, required this.webSendState, + required this.webUpload, }); @override 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)'; } } diff --git a/app/lib/model/state/server/server_state.mapper.dart b/app/lib/model/state/server/server_state.mapper.dart index 15e711a7..585c35d2 100644 --- a/app/lib/model/state/server/server_state.mapper.dart +++ b/app/lib/model/state/server/server_state.mapper.dart @@ -40,6 +40,11 @@ class ServerStateMapper extends ClassMapperBase { 'webSendState', _$webSendState, ); + static bool _$webUpload(ServerState v) => v.webUpload; + static const Field _f$webUpload = Field( + 'webUpload', + _$webUpload, + ); @override final MappableFields fields = const { @@ -48,6 +53,7 @@ class ServerStateMapper extends ClassMapperBase { #https: _f$https, #session: _f$session, #webSendState: _f$webSendState, + #webUpload: _f$webUpload, }; static ServerState _instantiate(DecodingData data) { @@ -57,6 +63,7 @@ class ServerStateMapper extends ClassMapperBase { https: data.dec(_f$https), session: data.dec(_f$session), webSendState: data.dec(_f$webSendState), + webUpload: data.dec(_f$webUpload), ); } @@ -129,6 +136,7 @@ abstract class ServerStateCopyWith<$R, $In extends ServerState, $Out> bool? https, ReceiveSessionState? session, WebSendState? webSendState, + bool? webUpload, }); ServerStateCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); } @@ -154,6 +162,7 @@ class _ServerStateCopyWithImpl<$R, $Out> bool? https, Object? session = $none, Object? webSendState = $none, + bool? webUpload, }) => $apply( FieldCopyWithData({ if (alias != null) #alias: alias, @@ -161,6 +170,7 @@ class _ServerStateCopyWithImpl<$R, $Out> if (https != null) #https: https, if (session != $none) #session: session, if (webSendState != $none) #webSendState: webSendState, + if (webUpload != null) #webUpload: webUpload, }), ); @override @@ -170,6 +180,7 @@ class _ServerStateCopyWithImpl<$R, $Out> https: data.get(#https, or: $value.https), session: data.get(#session, or: $value.session), webSendState: data.get(#webSendState, or: $value.webSendState), + webUpload: data.get(#webUpload, or: $value.webUpload), ); @override diff --git a/app/lib/model/state/settings_state.dart b/app/lib/model/state/settings_state.dart index d3d7947c..5155ba27 100644 --- a/app/lib/model/state/settings_state.dart +++ b/app/lib/model/state/settings_state.dart @@ -33,6 +33,7 @@ class SettingsState with SettingsStateMappable { final DeviceType? deviceType; final String? deviceModel; final bool shareViaLinkAutoAccept; + final bool receiveViaLinkAutoAccept; final int discoveryTimeout; final bool advancedSettings; @@ -61,6 +62,7 @@ class SettingsState with SettingsStateMappable { required this.deviceType, required this.deviceModel, required this.shareViaLinkAutoAccept, + required this.receiveViaLinkAutoAccept, required this.discoveryTimeout, required this.advancedSettings, }); diff --git a/app/lib/model/state/settings_state.mapper.dart b/app/lib/model/state/settings_state.mapper.dart index 44f9c183..4c107081 100644 --- a/app/lib/model/state/settings_state.mapper.dart +++ b/app/lib/model/state/settings_state.mapper.dart @@ -138,6 +138,12 @@ class SettingsStateMapper extends ClassMapperBase { 'shareViaLinkAutoAccept', _$shareViaLinkAutoAccept, ); + static bool _$receiveViaLinkAutoAccept(SettingsState v) => + v.receiveViaLinkAutoAccept; + static const Field _f$receiveViaLinkAutoAccept = Field( + 'receiveViaLinkAutoAccept', + _$receiveViaLinkAutoAccept, + ); static int _$discoveryTimeout(SettingsState v) => v.discoveryTimeout; static const Field _f$discoveryTimeout = Field( 'discoveryTimeout', @@ -175,6 +181,7 @@ class SettingsStateMapper extends ClassMapperBase { #deviceType: _f$deviceType, #deviceModel: _f$deviceModel, #shareViaLinkAutoAccept: _f$shareViaLinkAutoAccept, + #receiveViaLinkAutoAccept: _f$receiveViaLinkAutoAccept, #discoveryTimeout: _f$discoveryTimeout, #advancedSettings: _f$advancedSettings, }; @@ -205,6 +212,7 @@ class SettingsStateMapper extends ClassMapperBase { deviceType: data.dec(_f$deviceType), deviceModel: data.dec(_f$deviceModel), shareViaLinkAutoAccept: data.dec(_f$shareViaLinkAutoAccept), + receiveViaLinkAutoAccept: data.dec(_f$receiveViaLinkAutoAccept), discoveryTimeout: data.dec(_f$discoveryTimeout), advancedSettings: data.dec(_f$advancedSettings), ); @@ -301,6 +309,7 @@ abstract class SettingsStateCopyWith<$R, $In extends SettingsState, $Out> DeviceType? deviceType, String? deviceModel, bool? shareViaLinkAutoAccept, + bool? receiveViaLinkAutoAccept, int? discoveryTimeout, bool? advancedSettings, }); @@ -359,6 +368,7 @@ class _SettingsStateCopyWithImpl<$R, $Out> Object? deviceType = $none, Object? deviceModel = $none, bool? shareViaLinkAutoAccept, + bool? receiveViaLinkAutoAccept, int? discoveryTimeout, bool? advancedSettings, }) => $apply( @@ -390,6 +400,8 @@ class _SettingsStateCopyWithImpl<$R, $Out> if (deviceModel != $none) #deviceModel: deviceModel, if (shareViaLinkAutoAccept != null) #shareViaLinkAutoAccept: shareViaLinkAutoAccept, + if (receiveViaLinkAutoAccept != null) + #receiveViaLinkAutoAccept: receiveViaLinkAutoAccept, if (discoveryTimeout != null) #discoveryTimeout: discoveryTimeout, if (advancedSettings != null) #advancedSettings: advancedSettings, }), @@ -429,6 +441,10 @@ class _SettingsStateCopyWithImpl<$R, $Out> #shareViaLinkAutoAccept, or: $value.shareViaLinkAutoAccept, ), + receiveViaLinkAutoAccept: data.get( + #receiveViaLinkAutoAccept, + or: $value.receiveViaLinkAutoAccept, + ), discoveryTimeout: data.get(#discoveryTimeout, or: $value.discoveryTimeout), advancedSettings: data.get(#advancedSettings, or: $value.advancedSettings), ); diff --git a/app/lib/pages/progress_page.dart b/app/lib/pages/progress_page.dart index 130470a4..2a0e33ee 100644 --- a/app/lib/pages/progress_page.dart +++ b/app/lib/pages/progress_page.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:localsend_app/config/theme.dart'; import 'package:localsend_app/gen/strings.g.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/server/server_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/util/file_size_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:routerino/routerino.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; @@ -140,8 +142,11 @@ class _ProgressPageState extends State with Refena { final result = status == null || keepSession || await _askCancelConfirmation(status); if (result && mounted) { - // ignore: unawaited_futures - context.popUntilRoot(); + if (ref.read(serverProvider)?.webUpload == true) { + context.global.dispatch(NavigateAction.popUntil()); + } else { + context.global.dispatch(NavigateAction.popUntilRoot()); + } } } diff --git a/app/lib/pages/tabs/receive_tab.dart b/app/lib/pages/tabs/receive_tab.dart index 83c65cef..21f1ba5d 100644 --- a/app/lib/pages/tabs/receive_tab.dart +++ b/app/lib/pages/tabs/receive_tab.dart @@ -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/receive_history_page.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/util/ip_helper.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/responsive_list_view.dart'; import 'package:localsend_app/widget/rotating_widget.dart'; +import 'package:refena_flutter/addons.dart'; import 'package:refena_flutter/refena_flutter.dart'; import 'package:routerino/routerino.dart'; -enum _QuickSaveMode { - off, - favorites, - on, -} - class ReceiveTab extends StatelessWidget { const ReceiveTab(); @@ -76,53 +72,12 @@ class ReceiveTab extends StatelessWidget { Padding( padding: const EdgeInsets.only(top: 10), child: Center( - child: Column( - children: [ - Text(t.general.quickSave), - 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: { - if (!vm.quickSaveSettings && !vm.quickSaveFromFavoritesSettings) _QuickSaveMode.off, - 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), - ), - ], - ), - ], + child: OutlinedButton.icon( + onPressed: () async { + await context.global.dispatchAsync(NavigateAction.push(WebReceivePage())); + }, + icon: Icon(Icons.language), + label: Text(t.$wip.receiveTab.link('Receive via link')), ), ), ), diff --git a/app/lib/pages/web_receive_page.dart b/app/lib/pages/web_receive_page.dart new file mode 100644 index 00000000..83549558 --- /dev/null +++ b/app/lib/pages/web_receive_page.dart @@ -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 createState() => _WebReceivePageState(); +} + +class _WebReceivePageState extends State 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 _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); + }, + ), + ], + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/app/lib/provider/network/server/controller/receive_controller.dart b/app/lib/provider/network/server/controller/receive_controller.dart index e8c966cd..adc4479a 100644 --- a/app/lib/provider/network/server/controller/receive_controller.dart +++ b/app/lib/provider/network/server/controller/receive_controller.dart @@ -128,6 +128,10 @@ class ReceiveController { 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) { // accept all files diff --git a/app/lib/provider/network/server/server_provider.dart b/app/lib/provider/network/server/server_provider.dart index 50407d92..809932cb 100644 --- a/app/lib/provider/network/server/server_provider.dart +++ b/app/lib/provider/network/server/server_provider.dart @@ -12,7 +12,7 @@ import 'package:localsend_app/util/alias_generator.dart'; import 'package:localsend_isolates/constants.dart'; import 'package:localsend_isolates/isolate.dart'; import 'package:localsend_isolates/model/dto/multicast_dto.dart'; -import 'package:localsend_isolates/rust/api/server.dart' show WebSendI18n, WebSendParams; +import 'package:localsend_isolates/rust/api/server.dart' show WebI18n, WebParams, WebSendParams; import 'package:localsend_isolates/util/rust.dart'; import 'package:logging/logging.dart'; import 'package:refena_flutter/refena_flutter.dart'; @@ -92,11 +92,13 @@ class ServerService extends Notifier { /// Starts the server. /// Passing a [webSendState] additionally serves the web send (download) API. + /// Passing [webUpload] serves the upload page so web browsers can upload files. Future startServer({ required String alias, required int port, required bool https, WebSendState? webSendState, + bool webUpload = false, }) async { if (state != null) { _logger.info('Server already running.'); @@ -124,18 +126,25 @@ class ServerService extends Notifier { .dispatchTakeResult( IsolateHttpServerStartAction( pin: settings.receivePin, - webSend: webSendState != null - ? WebSendParams( - files: { - for (final entry in webSendState.files.entries) entry.key: entry.value.file.toRust(), - }, - pin: webSendState.pin, - i18N: WebSendI18n( + web: webSendState != null || webUpload + ? WebParams( + send: webSendState != null + ? WebSendParams( + files: { + for (final entry in webSendState.files.entries) entry.key: entry.value.file.toRust(), + }, + pin: webSendState.pin, + ) + : null, + upload: webUpload, + i18N: WebI18n( waiting: t.web.waiting, enterPin: t.web.enterPin, invalidPin: t.web.invalidPin, tooManyAttempts: t.web.tooManyAttempts, rejected: t.web.rejected, + uploadRejected: t.sendPage.rejected, + busy: t.sendPage.busy, files: t.web.files, fileName: t.web.fileName, size: t.web.size, @@ -183,6 +192,7 @@ class ServerService extends Notifier { https: https, session: null, webSendState: webSendState, + webUpload: webUpload, ); state = newServerState; @@ -204,9 +214,15 @@ class ServerService extends Notifier { return await startServerFromSettings(); } - Future restartServer({required String alias, required int port, required bool https, WebSendState? webSendState}) async { + Future restartServer({ + required String alias, + required int port, + required bool https, + WebSendState? webSendState, + bool webUpload = false, + }) async { 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 acceptFileRequest(Map fileNameMap) async { diff --git a/app/lib/provider/persistence_provider.dart b/app/lib/provider/persistence_provider.dart index d0108c6d..d7b07357 100644 --- a/app/lib/provider/persistence_provider.dart +++ b/app/lib/provider/persistence_provider.dart @@ -89,6 +89,7 @@ const _enableAnimations = 'ls_enable_animations'; const _deviceType = 'ls_device_type'; const _deviceModel = 'ls_device_model'; const _shareViaLinkAutoAccept = 'ls_share_via_link_auto_accept'; +const _receiveViaLinkAutoAccept = 'ls_receive_via_link_auto_accept'; const _advancedSettingsKey = 'ls_advanced_settings'; const _whatsNewKey = 'ls_whats_new'; @@ -368,6 +369,14 @@ class PersistenceService { await _prefs.setBool(_shareViaLinkAutoAccept, shareViaLinkAutoAccept); } + bool getReceiveViaLinkAutoAccept() { + return _prefs.getBool(_receiveViaLinkAutoAccept) ?? false; + } + + Future setReceiveViaLinkAutoAccept(bool receiveViaLinkAutoAccept) async { + await _prefs.setBool(_receiveViaLinkAutoAccept, receiveViaLinkAutoAccept); + } + String getMulticastGroup() { return _prefs.getString(_multicastGroupKey) ?? defaultMulticastGroup; } diff --git a/app/lib/provider/settings_provider.dart b/app/lib/provider/settings_provider.dart index 8a0cf036..3127abb5 100644 --- a/app/lib/provider/settings_provider.dart +++ b/app/lib/provider/settings_provider.dart @@ -69,6 +69,7 @@ class SettingsService extends PureNotifier { deviceType: _persistence.getDeviceType(), deviceModel: _persistence.getDeviceModel(), shareViaLinkAutoAccept: _persistence.getShareViaLinkAutoAccept(), + receiveViaLinkAutoAccept: _persistence.getReceiveViaLinkAutoAccept(), discoveryTimeout: _persistence.getDiscoveryTimeout(), advancedSettings: _persistence.getAdvancedSettingsEnabled(), ); @@ -264,4 +265,12 @@ class SettingsService extends PureNotifier { shareViaLinkAutoAccept: shareViaLinkAutoAccept, ); } + + Future setReceiveViaLinkAutoAccept(bool receiveViaLinkAutoAccept) async { + await _persistence.setReceiveViaLinkAutoAccept(receiveViaLinkAutoAccept); + + state = state.copyWith( + receiveViaLinkAutoAccept: receiveViaLinkAutoAccept, + ); + } } diff --git a/app/pubspec.lock b/app/pubspec.lock index cb55dedf..a1b03af4 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -1315,18 +1315,18 @@ packages: dependency: transitive description: name: refena - sha256: d2bb36fd39412563a4515b8228271cf8786936f77d4a6261b05eecf648e59b6b + sha256: bd609a1015728db75af79df295c44ccf60507f965e0c32b447d67c48816c7d3a url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.4.0" refena_flutter: dependency: "direct main" description: name: refena_flutter - sha256: "18f704c3ba38a30aa05ae71b3c55777b8d09864570e3b88b2f8814644381e461" + sha256: "36417ef3a60c9c134286ab3f8491d98b07f40b2163b994c4a0c905831415a6fc" url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.4.0" refena_inspector: dependency: "direct dev" description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index fc876deb..4b5b3d7b 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -48,7 +48,7 @@ dependencies: path_provider_foundation: 2.6.0 permission_handler: 12.0.3 pretty_qr_code: 3.6.0 - refena_flutter: 3.2.1 + refena_flutter: 3.4.0 refena_inspector_client: 2.1.1 routerino: 0.8.1 saf_stream: 2.0.0 diff --git a/app/test/mocks.mocks.dart b/app/test/mocks.mocks.dart index fe65f784..27c7e037 100644 --- a/app/test/mocks.mocks.dart +++ b/app/test/mocks.mocks.dart @@ -296,6 +296,28 @@ class MockPersistenceService extends _i1.Mock implements _i3.PersistenceService ) as _i4.Future); + @override + bool getReceiveViaLinkAutoAccept() => + (super.noSuchMethod( + Invocation.method(#getReceiveViaLinkAutoAccept, []), + returnValue: false, + returnValueForMissingStub: false, + ) + as bool); + + @override + _i4.Future setReceiveViaLinkAutoAccept( + bool? receiveViaLinkAutoAccept, + ) => + (super.noSuchMethod( + Invocation.method(#setReceiveViaLinkAutoAccept, [ + receiveViaLinkAutoAccept, + ]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + @override String getMulticastGroup() => (super.noSuchMethod( @@ -572,6 +594,15 @@ class MockPersistenceService extends _i1.Mock implements _i3.PersistenceService ) as _i4.Future); + @override + _i4.Future setWhatsNew(String? version) => + (super.noSuchMethod( + Invocation.method(#setWhatsNew, [version]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + @override _i4.Future clear() => (super.noSuchMethod( diff --git a/packages/core/assets/web/index.html b/packages/core/assets/web/download.html similarity index 97% rename from packages/core/assets/web/index.html rename to packages/core/assets/web/download.html index 974ea94c..5097d347 100644 --- a/packages/core/assets/web/index.html +++ b/packages/core/assets/web/download.html @@ -83,7 +83,7 @@ margin: 0 auto; } - +