From a85ad89ffceea89d1d863e2b350cdc63cca04be6 Mon Sep 17 00:00:00 2001 From: 21pages Date: Mon, 2 Nov 2026 23:48:27 +0800 Subject: [PATCH] feat: request approval (#16784) * feat: add connections without sending passwords Add Without password actions and pass the selection through desktop, mobile and Web sessions. Omit the password when constructing LoginRequest, preserve saved credentials, and reuse the existing approval and OS login dialogs. Keep protocol and controlled-side authentication changes separate. * fix: preserve login method across session windows * fix: skip password persistence for consent connections * refactor: unify request approval menus and naming * chore: move request approval translation key to the end * i18n: translate request approval menu label * fix: align request approval menu layout and icons * fix: avoid duplicate focus stops in request approval menus * fix: distinguish request approval submenus on mobile * fix: open overflowing submenus to the left * Revert "fix: distinguish request approval submenus on mobile" This reverts commit 7f0e50d4e4503bfb99b0befe9d147a365f21e287. --- flutter/lib/common.dart | 48 ++- flutter/lib/common/widgets/dialog.dart | 18 +- flutter/lib/common/widgets/peer_card.dart | 209 ++++++------ .../common/widgets/request_approval_menu.dart | 52 +++ flutter/lib/common/widgets/toolbar.dart | 3 + .../lib/desktop/pages/connection_page.dart | 40 ++- .../lib/desktop/pages/desktop_home_page.dart | 1 + .../lib/desktop/pages/file_manager_page.dart | 3 + .../desktop/pages/file_manager_tab_page.dart | 2 + .../lib/desktop/pages/port_forward_page.dart | 3 + .../desktop/pages/port_forward_tab_page.dart | 2 + flutter/lib/desktop/pages/remote_page.dart | 3 + .../lib/desktop/pages/remote_tab_page.dart | 2 + .../pages/terminal_connection_manager.dart | 2 + flutter/lib/desktop/pages/terminal_page.dart | 3 + .../lib/desktop/pages/terminal_tab_page.dart | 5 + .../lib/desktop/pages/view_camera_page.dart | 3 + .../desktop/pages/view_camera_tab_page.dart | 2 + .../widgets/material_mod_popup_menu.dart | 23 +- flutter/lib/desktop/widgets/popup_menu.dart | 70 ++++- .../lib/mobile/pages/file_manager_page.dart | 3 + flutter/lib/mobile/pages/home_page.dart | 5 + flutter/lib/mobile/pages/remote_page.dart | 3 + flutter/lib/mobile/pages/terminal_page.dart | 3 + .../lib/mobile/pages/view_camera_page.dart | 3 + flutter/lib/models/model.dart | 9 +- flutter/lib/utils/multi_window_manager.dart | 16 + flutter/lib/web/bridge.dart | 5 + flutter/test/request_approval_menu_test.dart | 297 ++++++++++++++++++ flutter/test/uri_login_method_test.dart | 46 +++ src/client.rs | 37 ++- src/client/login_method.rs | 274 ++++++++++++++++ src/core_main.rs | 6 +- src/flutter.rs | 2 + src/flutter_ffi.rs | 4 + src/lang/ar.rs | 1 + src/lang/az.rs | 1 + src/lang/be.rs | 1 + src/lang/bg.rs | 1 + src/lang/ca.rs | 1 + src/lang/cn.rs | 1 + src/lang/cs.rs | 1 + src/lang/da.rs | 1 + src/lang/de.rs | 1 + src/lang/el.rs | 1 + src/lang/eo.rs | 1 + src/lang/es.rs | 1 + src/lang/et.rs | 1 + src/lang/eu.rs | 1 + src/lang/fa.rs | 1 + src/lang/fi.rs | 1 + src/lang/fr.rs | 1 + src/lang/ge.rs | 1 + src/lang/gl.rs | 1 + src/lang/gu.rs | 1 + src/lang/he.rs | 1 + src/lang/hi.rs | 1 + src/lang/hr.rs | 1 + src/lang/hu.rs | 1 + src/lang/id.rs | 1 + src/lang/it.rs | 1 + src/lang/ja.rs | 1 + src/lang/ko.rs | 1 + src/lang/kz.rs | 1 + src/lang/lt.rs | 1 + src/lang/lv.rs | 1 + src/lang/ml.rs | 1 + src/lang/nb.rs | 1 + src/lang/nl.rs | 1 + src/lang/pl.rs | 1 + src/lang/pt_BR.rs | 1 + src/lang/pt_PT.rs | 1 + src/lang/ro.rs | 1 + src/lang/ru.rs | 1 + src/lang/sc.rs | 1 + src/lang/sk.rs | 1 + src/lang/sl.rs | 1 + src/lang/sq.rs | 1 + src/lang/sr.rs | 1 + src/lang/sv.rs | 1 + src/lang/ta.rs | 1 + src/lang/template.rs | 1 + src/lang/th.rs | 1 + src/lang/tr.rs | 1 + src/lang/tw.rs | 1 + src/lang/uk.rs | 1 + src/lang/ur.rs | 1 + src/lang/vi.rs | 1 + src/ui/remote.rs | 2 +- 89 files changed, 1110 insertions(+), 152 deletions(-) create mode 100644 flutter/lib/common/widgets/request_approval_menu.dart create mode 100644 flutter/test/request_approval_menu_test.dart create mode 100644 flutter/test/uri_login_method_test.dart create mode 100644 src/client/login_method.rs diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index b80e0b5f9..17dc0fcff 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -2286,6 +2286,7 @@ bool handleUriLink({List? cmdArgs, Uri? uri, String? uriString}) { String? password; String? switchUuid; bool? forceRelay; + var loginMethod = ''; for (int i = 0; i < args.length; i++) { switch (args[i]) { case '--connect': @@ -2329,6 +2330,9 @@ bool handleUriLink({List? cmdArgs, Uri? uri, String? uriString}) { password = args[i + 1]; i++; break; + case '--request-approval': + loginMethod = 'request-approval'; + break; case '--switch_uuid': switchUuid = args[i + 1]; i++; @@ -2340,11 +2344,15 @@ bool handleUriLink({List? cmdArgs, Uri? uri, String? uriString}) { break; } } + if (loginMethod == 'request-approval') { + password = null; + } if (type != null && id != null) { switch (type) { case UriLinkType.remoteDesktop: Future.delayed(Duration.zero, () { rustDeskWinManager.newRemoteDesktop(id!, + loginMethod: loginMethod, password: password, switchUuid: switchUuid, forceRelay: forceRelay); @@ -2353,30 +2361,35 @@ bool handleUriLink({List? cmdArgs, Uri? uri, String? uriString}) { case UriLinkType.fileTransfer: Future.delayed(Duration.zero, () { rustDeskWinManager.newFileTransfer(id!, + loginMethod: loginMethod, password: password, forceRelay: forceRelay); }); break; case UriLinkType.viewCamera: Future.delayed(Duration.zero, () { rustDeskWinManager.newViewCamera(id!, + loginMethod: loginMethod, password: password, forceRelay: forceRelay); }); break; case UriLinkType.portForward: Future.delayed(Duration.zero, () { rustDeskWinManager.newPortForward(id!, false, + loginMethod: loginMethod, password: password, forceRelay: forceRelay); }); break; case UriLinkType.rdp: Future.delayed(Duration.zero, () { rustDeskWinManager.newPortForward(id!, true, + loginMethod: loginMethod, password: password, forceRelay: forceRelay); }); break; case UriLinkType.terminal: Future.delayed(Duration.zero, () { rustDeskWinManager.newTerminal(id!, + loginMethod: loginMethod, password: password, forceRelay: forceRelay); }); break; @@ -2473,6 +2486,10 @@ List? urlLinkToCmdArgs(Uri uri) { var queryParameters = uri.queryParameters.map((k, v) => MapEntry(k.toLowerCase(), v)); + final requestApproval = queryParameters['request-approval'] == 'true'; + if (requestApproval) { + queryParameters.remove('password'); + } var key = queryParameters["key"]; if (id != null) { @@ -2484,24 +2501,30 @@ List? urlLinkToCmdArgs(Uri uri) { if (isMobile && id != null) { final forceRelay = queryParameters["relay"] != null; final password = queryParameters["password"]; + final loginMethod = requestApproval ? 'request-approval' : ''; // Determine connection type based on command if (command == '--file-transfer') { connect(Get.context!, id, + loginMethod: loginMethod, isFileTransfer: true, forceRelay: forceRelay, password: password); } else if (command == '--view-camera') { connect(Get.context!, id, + loginMethod: loginMethod, isViewCamera: true, forceRelay: forceRelay, password: password); } else if (command == '--terminal') { connect(Get.context!, id, + loginMethod: loginMethod, isTerminal: true, forceRelay: forceRelay, password: password); - } else if (command == 'terminal-admin') { + } else if (command == '--terminal-admin') { setEnvTerminalAdmin(); connect(Get.context!, id, + loginMethod: loginMethod, isTerminal: true, forceRelay: forceRelay, password: password); } else { // Default to remote desktop for '--connect', '--play', or direct connection - connect(Get.context!, id, forceRelay: forceRelay, password: password); + connect(Get.context!, id, + loginMethod: loginMethod, forceRelay: forceRelay, password: password); } return null; } @@ -2513,6 +2536,7 @@ List? urlLinkToCmdArgs(Uri uri) { var param = queryParameters; String? password = param["password"]; if (password != null) args.addAll(['--password', password]); + if (requestApproval) args.add('--request-approval'); String? switch_uuid = param["switch_uuid"]; if (switch_uuid != null) args.addAll(['--switch_uuid', switch_uuid]); if (param["relay"] != null) args.add("--relay"); @@ -2531,33 +2555,39 @@ connectMainDesktop(String id, bool? forceRelay, String? password, String? connToken, - bool? isSharedPassword}) async { + bool? isSharedPassword, + String loginMethod = ''}) async { if (isFileTransfer) { await rustDeskWinManager.newFileTransfer(id, + loginMethod: loginMethod, password: password, isSharedPassword: isSharedPassword, connToken: connToken, forceRelay: forceRelay); } else if (isViewCamera) { await rustDeskWinManager.newViewCamera(id, + loginMethod: loginMethod, password: password, isSharedPassword: isSharedPassword, connToken: connToken, forceRelay: forceRelay); } else if (isTcpTunneling || isRDP) { await rustDeskWinManager.newPortForward(id, isRDP, + loginMethod: loginMethod, password: password, isSharedPassword: isSharedPassword, connToken: connToken, forceRelay: forceRelay); } else if (isTerminal) { await rustDeskWinManager.newTerminal(id, + loginMethod: loginMethod, password: password, isSharedPassword: isSharedPassword, connToken: connToken, forceRelay: forceRelay); } else { await rustDeskWinManager.newRemoteDesktop(id, + loginMethod: loginMethod, password: password, isSharedPassword: isSharedPassword, forceRelay: forceRelay); @@ -2578,7 +2608,8 @@ connect(BuildContext context, String id, bool forceRelay = false, String? password, String? connToken, - bool? isSharedPassword}) async { + bool? isSharedPassword, + String loginMethod = ''}) async { if (id == '') return; if (!isDesktop || desktopType == DesktopType.main) { try { @@ -2603,6 +2634,7 @@ connect(BuildContext context, String id, if (desktopType == DesktopType.main) { await connectMainDesktop( id, + loginMethod: loginMethod, isFileTransfer: isFileTransfer, isViewCamera: isViewCamera, isTerminal: isTerminal, @@ -2624,6 +2656,7 @@ connect(BuildContext context, String id, 'isSharedPassword': isSharedPassword, 'forceRelay': forceRelay, 'connToken': connToken, + 'loginMethod': loginMethod, }); } } else { @@ -2635,6 +2668,7 @@ connect(BuildContext context, String id, builder: (BuildContext context) => desktop_file_manager.FileManagerPage( id: id, + loginMethod: loginMethod, password: password, isSharedPassword: isSharedPassword), ), @@ -2645,6 +2679,7 @@ connect(BuildContext context, String id, MaterialPageRoute( builder: (BuildContext context) => FileManagerPage( id: id, + loginMethod: loginMethod, password: password, isSharedPassword: isSharedPassword, forceRelay: forceRelay), @@ -2660,6 +2695,7 @@ connect(BuildContext context, String id, desktop_view_camera.ViewCameraPage( key: ValueKey(id), id: id, + loginMethod: loginMethod, toolbarState: ToolbarState(), password: password, isSharedPassword: isSharedPassword, @@ -2672,6 +2708,7 @@ connect(BuildContext context, String id, MaterialPageRoute( builder: (BuildContext context) => ViewCameraPage( id: id, + loginMethod: loginMethod, password: password, isSharedPassword: isSharedPassword, forceRelay: forceRelay), @@ -2684,6 +2721,7 @@ connect(BuildContext context, String id, MaterialPageRoute( builder: (BuildContext context) => TerminalPage( id: id, + loginMethod: loginMethod, password: password, isSharedPassword: isSharedPassword, forceRelay: forceRelay, @@ -2698,6 +2736,7 @@ connect(BuildContext context, String id, builder: (BuildContext context) => desktop_remote.RemotePage( key: ValueKey(id), id: id, + loginMethod: loginMethod, toolbarState: ToolbarState(), password: password, isSharedPassword: isSharedPassword, @@ -2710,6 +2749,7 @@ connect(BuildContext context, String id, MaterialPageRoute( builder: (BuildContext context) => RemotePage( id: id, + loginMethod: loginMethod, password: password, isSharedPassword: isSharedPassword, forceRelay: forceRelay), diff --git a/flutter/lib/common/widgets/dialog.dart b/flutter/lib/common/widgets/dialog.dart index c756818b0..cef49cdd2 100644 --- a/flutter/lib/common/widgets/dialog.dart +++ b/flutter/lib/common/widgets/dialog.dart @@ -937,13 +937,15 @@ void enterPasswordDialog( } void enterUserLoginDialog(SessionID sessionId, - OverlayDialogManager dialogManager, String osAccountDescTip) async { + OverlayDialogManager dialogManager, String osAccountDescTip, + {bool waitForAccept = false}) async { await _connectDialog( sessionId, dialogManager, osUsernameController: TextEditingController(), osPasswordController: TextEditingController(), osAccountDescTip: osAccountDescTip, + waitForAccept: waitForAccept, ); } @@ -966,6 +968,7 @@ _connectDialog( TextEditingController? osPasswordController, TextEditingController? passwordController, String? osAccountDescTip, + bool waitForAccept = false, }) async { final errUsername = ''.obs; var rememberPassword = false; @@ -1008,8 +1011,17 @@ _connectDialog( rememberPassword, ); close(); - dialogManager.showLoading(translate('Logging in...'), - onCancel: closeConnection); + if (waitForAccept) { + showWaitAcceptDialog( + sessionId, + 'wait-remote-accept-nook', + 'Prompt', + 'Please wait for the remote side to accept your session request...', + dialogManager); + } else { + dialogManager.showLoading(translate('Logging in...'), + onCancel: closeConnection); + } } descWidget(String text) { diff --git a/flutter/lib/common/widgets/peer_card.dart b/flutter/lib/common/widgets/peer_card.dart index 86ce7c623..4a15e3837 100644 --- a/flutter/lib/common/widgets/peer_card.dart +++ b/flutter/lib/common/widgets/peer_card.dart @@ -15,6 +15,7 @@ import '../../models/peer_model.dart'; import '../../models/platform_model.dart'; import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu; import '../../desktop/widgets/popup_menu.dart'; +import 'request_approval_menu.dart'; import 'dart:math' as math; typedef PopupMenuEntryBuilder = Future>> @@ -572,7 +573,36 @@ abstract class BasePeerCard extends StatelessWidget { @protected Future>> _buildMenuItems(BuildContext context); - MenuEntryBase _connectCommonAction( + @protected + Future>> _buildConnectionMenuItems( + BuildContext context) async { + final List> menuItems = [ + _connectAction(context), + _transferFileAction(context), + _viewCameraAction(context), + _terminalAction(context), + ]; + + if (peer.platform == kPeerPlatformWindows) { + menuItems.add(_terminalRunAsAdminAction(context)); + } + + if (isDesktop && peer.platform != kPeerPlatformAndroid) { + menuItems.add(_tcpTunnelingAction(context)); + } + if (isWindows && peer.platform == kPeerPlatformWindows) { + menuItems.add(_rdpAction(context, peer.id)); + } + menuItems.add(MenuEntryDivider()); + menuItems.add(_requestApprovalAction(context)); + // menuItems.add(await _openNewConnInOptAction(peer.id)); + if (!isWeb) { + menuItems.add(await _forceAlwaysRelayAction(peer.id)); + } + return menuItems; + } + + MenuEntryButton _connectCommonAction( BuildContext context, String title, { bool isFileTransfer = false, @@ -581,12 +611,10 @@ abstract class BasePeerCard extends StatelessWidget { bool isRDP = false, bool isTerminal = false, bool isTerminalRunAsAdmin = false, + String loginMethod = '', }) { return MenuEntryButton( - childBuilder: (TextStyle? style) => Text( - title, - style: style, - ), + childBuilder: (TextStyle? style) => Text(title, style: style), proc: () { if (isTerminalRunAsAdmin) { setEnvTerminalAdmin(); @@ -600,6 +628,7 @@ abstract class BasePeerCard extends StatelessWidget { isTcpTunneling: isTcpTunneling, isRDP: isRDP, isTerminal: isTerminal || isTerminalRunAsAdmin, + loginMethod: loginMethod, ); }, padding: menuPadding, @@ -607,13 +636,53 @@ abstract class BasePeerCard extends StatelessWidget { ); } + MenuEntryBase _requestApprovalAction(BuildContext context) { + MenuEntryButton action(String title, + {bool isFileTransfer = false, + bool isViewCamera = false, + bool isTcpTunneling = false, + bool isRDP = false, + bool isTerminal = false, + bool isTerminalRunAsAdmin = false}) => + _connectCommonAction( + context, + isTerminal || isTerminalRunAsAdmin + ? '${translate(title)} (beta)' + : translate(title), + isFileTransfer: isFileTransfer, + isViewCamera: isViewCamera, + isTcpTunneling: isTcpTunneling, + isRDP: isRDP, + isTerminal: isTerminal, + isTerminalRunAsAdmin: isTerminalRunAsAdmin, + loginMethod: 'request-approval', + ); + + return RequestApprovalMenu( + title: translate('Request approval'), + padding: menuPadding, + entries: [ + action('Connect'), + action('Transfer file', isFileTransfer: true), + action('View camera', isViewCamera: true), + action('Terminal', isTerminal: true), + if (peer.platform == kPeerPlatformWindows) + action('Terminal (Run as administrator)', isTerminalRunAsAdmin: true), + if (isDesktop && peer.platform != kPeerPlatformAndroid) + action('TCP tunneling', isTcpTunneling: true), + if (isWindows && peer.platform == kPeerPlatformWindows) + action('RDP', isRDP: true), + ], + ); + } + @protected MenuEntryBase _connectAction(BuildContext context) { return _connectCommonAction( context, - (peer.alias.isEmpty + peer.alias.isEmpty ? translate('Connect') - : '${translate('Connect')} ${peer.id}'), + : '${translate('Connect')} ${peer.id}', ); } @@ -682,6 +751,10 @@ abstract class BasePeerCard extends StatelessWidget { child: IconButton( icon: const Icon(Icons.edit), padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: kMinInteractiveDimension - 8, + minHeight: kMinInteractiveDimension, + ), onPressed: () { if (Navigator.canPop(context)) { Navigator.pop(context); @@ -1000,33 +1073,13 @@ class RecentPeerCard extends BasePeerCard { @override Future>> _buildMenuItems( BuildContext context) async { - final List> menuItems = [ - _connectAction(context), - _transferFileAction(context), - _viewCameraAction(context), - _terminalAction(context), - ]; - - if (peer.platform == kPeerPlatformWindows) { - menuItems.add(_terminalRunAsAdminAction(context)); - } - final List favs = (await bind.mainGetFav()).toList(); - if (isDesktop && peer.platform != kPeerPlatformAndroid) { - menuItems.add(_tcpTunnelingAction(context)); - } - // menuItems.add(await _openNewConnInOptAction(peer.id)); - if (!isWeb) { - menuItems.add(await _forceAlwaysRelayAction(peer.id)); - } - if (isWindows && peer.platform == kPeerPlatformWindows) { - menuItems.add(_rdpAction(context, peer.id)); - } + final menuItems = await _buildConnectionMenuItems(context); + menuItems.add(MenuEntryDivider()); if (isWindows) { menuItems.add(_createShortCutAction(peer.id)); } - menuItems.add(MenuEntryDivider()); if (isMobile || isDesktop || isWebDesktop) { menuItems.add(_renameAction(peer.id)); } @@ -1065,31 +1118,11 @@ class FavoritePeerCard extends BasePeerCard { @override Future>> _buildMenuItems( BuildContext context) async { - final List> menuItems = [ - _connectAction(context), - _transferFileAction(context), - _viewCameraAction(context), - _terminalAction(context), - ]; - - if (peer.platform == kPeerPlatformWindows) { - menuItems.add(_terminalRunAsAdminAction(context)); - } - - if (isDesktop && peer.platform != kPeerPlatformAndroid) { - menuItems.add(_tcpTunnelingAction(context)); - } - // menuItems.add(await _openNewConnInOptAction(peer.id)); - if (!isWeb) { - menuItems.add(await _forceAlwaysRelayAction(peer.id)); - } - if (isWindows && peer.platform == kPeerPlatformWindows) { - menuItems.add(_rdpAction(context, peer.id)); - } + final menuItems = await _buildConnectionMenuItems(context); + menuItems.add(MenuEntryDivider()); if (isWindows) { menuItems.add(_createShortCutAction(peer.id)); } - menuItems.add(MenuEntryDivider()); if (isMobile || isDesktop || isWebDesktop) { menuItems.add(_renameAction(peer.id)); } @@ -1125,29 +1158,10 @@ class DiscoveredPeerCard extends BasePeerCard { @override Future>> _buildMenuItems( BuildContext context) async { - final List> menuItems = [ - _connectAction(context), - _transferFileAction(context), - _viewCameraAction(context), - _terminalAction(context), - ]; - - if (peer.platform == kPeerPlatformWindows) { - menuItems.add(_terminalRunAsAdminAction(context)); - } - final List favs = (await bind.mainGetFav()).toList(); - if (isDesktop && peer.platform != kPeerPlatformAndroid) { - menuItems.add(_tcpTunnelingAction(context)); - } - // menuItems.add(await _openNewConnInOptAction(peer.id)); - if (!isWeb) { - menuItems.add(await _forceAlwaysRelayAction(peer.id)); - } - if (isWindows && peer.platform == kPeerPlatformWindows) { - menuItems.add(_rdpAction(context, peer.id)); - } + final menuItems = await _buildConnectionMenuItems(context); + menuItems.add(MenuEntryDivider()); menuItems.add(_wolAction(peer.id)); if (isWindows) { menuItems.add(_createShortCutAction(peer.id)); @@ -1184,32 +1198,12 @@ class AddressBookPeerCard extends BasePeerCard { @override Future>> _buildMenuItems( BuildContext context) async { - final List> menuItems = [ - _connectAction(context), - _transferFileAction(context), - _viewCameraAction(context), - _terminalAction(context), - ]; - - if (peer.platform == kPeerPlatformWindows) { - menuItems.add(_terminalRunAsAdminAction(context)); - } - - if (isDesktop && peer.platform != kPeerPlatformAndroid) { - menuItems.add(_tcpTunnelingAction(context)); - } - // menuItems.add(await _openNewConnInOptAction(peer.id)); - if (!isWeb) { - menuItems.add(await _forceAlwaysRelayAction(peer.id)); - } - if (isWindows && peer.platform == kPeerPlatformWindows) { - menuItems.add(_rdpAction(context, peer.id)); - } + final menuItems = await _buildConnectionMenuItems(context); + menuItems.add(MenuEntryDivider()); if (isWindows) { menuItems.add(_createShortCutAction(peer.id)); } if (gFFI.abModel.current.canWrite()) { - menuItems.add(MenuEntryDivider()); if (isMobile || isDesktop || isWebDesktop) { menuItems.add(_renameAction(peer.id)); } @@ -1341,26 +1335,9 @@ class MyGroupPeerCard extends BasePeerCard { @override Future>> _buildMenuItems( BuildContext context) async { - final List> menuItems = [ - _connectAction(context), - _transferFileAction(context), - _viewCameraAction(context), - _terminalAction(context), - ]; - - if (peer.platform == kPeerPlatformWindows) { - menuItems.add(_terminalRunAsAdminAction(context)); - } - - if (isDesktop && peer.platform != kPeerPlatformAndroid) { - menuItems.add(_tcpTunnelingAction(context)); - } - // menuItems.add(await _openNewConnInOptAction(peer.id)); - if (!isWeb) { - menuItems.add(await _forceAlwaysRelayAction(peer.id)); - } - if (isWindows && peer.platform == kPeerPlatformWindows) { - menuItems.add(_rdpAction(context, peer.id)); + final menuItems = await _buildConnectionMenuItems(context); + if (isWindows || gFFI.userModel.userName.isNotEmpty) { + menuItems.add(MenuEntryDivider()); } if (isWindows) { menuItems.add(_createShortCutAction(peer.id)); @@ -1591,7 +1568,8 @@ void connectInPeerTab(BuildContext context, Peer peer, PeerTabIndex tab, bool isViewCamera = false, bool isTcpTunneling = false, bool isRDP = false, - bool isTerminal = false}) async { + bool isTerminal = false, + String loginMethod = ''}) async { var password = ''; bool isSharedPassword = false; if (tab == PeerTabIndex.ab) { @@ -1619,6 +1597,7 @@ void connectInPeerTab(BuildContext context, Peer peer, PeerTabIndex tab, } } connect(context, peer.id, + loginMethod: loginMethod, password: password, isSharedPassword: isSharedPassword, isFileTransfer: isFileTransfer, diff --git a/flutter/lib/common/widgets/request_approval_menu.dart b/flutter/lib/common/widgets/request_approval_menu.dart new file mode 100644 index 000000000..2b6ee8bad --- /dev/null +++ b/flutter/lib/common/widgets/request_approval_menu.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu; +import '../../desktop/widgets/popup_menu.dart'; + +class RequestApprovalMenu extends MenuEntryBase { + final String title; + final List> entries; + final EdgeInsets? padding; + final double arrowScale; + + RequestApprovalMenu( + {required this.title, + required this.entries, + this.padding, + this.arrowScale = 1.0}); + + @override + List> build( + BuildContext context, MenuConfig conf) { + final theme = Theme.of(context); + final checkboxTheme = CheckboxTheme.of(context); + final checkboxWidth = + ((checkboxTheme.materialTapTargetSize ?? theme.materialTapTargetSize) == + MaterialTapTargetSize.padded + ? kMinInteractiveDimension + : kMinInteractiveDimension - 8) + + (checkboxTheme.visualDensity ?? theme.visualDensity) + .baseSizeAdjustment + .dx; + final arrowInset = (checkboxWidth - (IconTheme.of(context).size ?? 24)) / 2; + return MenuEntrySubMenu( + text: title, + dismissParentOnCanceled: true, + arrowScale: arrowScale, + padding: (padding ?? EdgeInsets.zero) + .copyWith(right: (padding?.right ?? 0) + arrowInset), + entries: entries + .map((entry) => MenuEntryButton( + childBuilder: entry.childBuilder, + proc: entry.proc, + padding: entry.padding, + enabled: entry.enabled, + dismissOnClicked: true, + dismissCallback: () { + if (Navigator.canPop(context)) Navigator.pop(context); + }, + )) + .toList(), + ).build(context, conf); + } +} diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 0ab0a4aa3..c1964d787 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -579,6 +579,9 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { bool isTerminal = false}) { final connToken = bind.sessionGetConnToken(sessionId: ffi.sessionId); connect(context, id, + loginMethod: bind.sessionGetCommonSync( + sessionId: ffi.sessionId, key: 'login_method', param: '') ?? + '', isFileTransfer: isFileTransfer, isViewCamera: isViewCamera, isTerminal: isTerminal, diff --git a/flutter/lib/desktop/pages/connection_page.dart b/flutter/lib/desktop/pages/connection_page.dart index 1d4928bbd..f484697ca 100644 --- a/flutter/lib/desktop/pages/connection_page.dart +++ b/flutter/lib/desktop/pages/connection_page.dart @@ -18,6 +18,7 @@ import '../../common.dart'; import '../../common/formatter/id_formatter.dart'; import '../../common/widgets/peer_tab_page.dart'; import '../../common/widgets/autocomplete.dart'; +import '../../common/widgets/request_approval_menu.dart'; import '../../models/platform_model.dart'; import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu; @@ -340,6 +341,38 @@ class _ConnectionPageState extends State isTcpTunneling: isTcpTunneling); } + RequestApprovalMenu _requestApprovalMenu(BuildContext context) { + MenuEntryButton action(String title, + {bool isFileTransfer = false, + bool isViewCamera = false, + bool isTerminal = false, + bool isTcpTunneling = false}) => + MenuEntryButton( + childBuilder: (style) => Text(translate(title), style: style), + proc: () => connect(context, _idController.id, + loginMethod: 'request-approval', + isFileTransfer: isFileTransfer, + isViewCamera: isViewCamera, + isTerminal: isTerminal, + isTcpTunneling: isTcpTunneling), + padding: EdgeInsets.symmetric(horizontal: kDesktopMenuPadding.left), + dismissOnClicked: true, + ); + + return RequestApprovalMenu( + title: translate('Request approval'), + padding: EdgeInsets.symmetric(horizontal: kDesktopMenuPadding.left), + arrowScale: MenuConfig.iconScale, + entries: [ + action('Connect'), + action('Transfer file', isFileTransfer: true), + action('View camera', isViewCamera: true), + action('${translate('Terminal')} (beta)', isTerminal: true), + if (isDesktop) action('TCP tunneling', isTcpTunneling: true), + ], + ); + } + /// UI for the remote ID TextField. /// Search for a peer. Widget _buildRemoteIDTextField(BuildContext context) { @@ -579,7 +612,8 @@ class _ConnectionPageState extends State () => onConnect(isTcpTunneling: true) ), ] - .map((e) => MenuEntryButton( + .map>( + (e) => MenuEntryButton( childBuilder: (TextStyle? style) => Text( translate(e.$1), @@ -591,6 +625,10 @@ class _ConnectionPageState extends State kDesktopMenuPadding.left), dismissOnClicked: true, )) + .followedBy([ + MenuEntryDivider(), + _requestApprovalMenu(context), + ]) .map((e) => e.build( context, const MenuConfig( diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index 1c7ae3d9b..12951ca4e 100644 --- a/flutter/lib/desktop/pages/desktop_home_page.dart +++ b/flutter/lib/desktop/pages/desktop_home_page.dart @@ -804,6 +804,7 @@ class _DesktopHomePageState extends State password: call.arguments['password'], forceRelay: call.arguments['forceRelay'], connToken: call.arguments['connToken'], + loginMethod: call.arguments['loginMethod'] ?? '', ); } else if (call.method == kWindowBumpMouse) { return RdPlatformChannel.instance.bumpMouse( diff --git a/flutter/lib/desktop/pages/file_manager_page.dart b/flutter/lib/desktop/pages/file_manager_page.dart index eb7cd2bfd..9491d9f8d 100644 --- a/flutter/lib/desktop/pages/file_manager_page.dart +++ b/flutter/lib/desktop/pages/file_manager_page.dart @@ -56,6 +56,7 @@ class FileManagerPage extends StatefulWidget { {Key? key, required this.id, required this.password, + this.loginMethod = '', required this.isSharedPassword, this.tabController, this.connToken, @@ -63,6 +64,7 @@ class FileManagerPage extends StatefulWidget { : super(key: key); final String id; final String? password; + final String loginMethod; final bool? isSharedPassword; final bool? forceRelay; final String? connToken; @@ -99,6 +101,7 @@ class _FileManagerPageState extends State _ffi.start(widget.id, isFileTransfer: true, password: widget.password, + loginMethod: widget.loginMethod, isSharedPassword: widget.isSharedPassword, connToken: widget.connToken, forceRelay: widget.forceRelay); diff --git a/flutter/lib/desktop/pages/file_manager_tab_page.dart b/flutter/lib/desktop/pages/file_manager_tab_page.dart index ed3e9682d..b6f633cf5 100644 --- a/flutter/lib/desktop/pages/file_manager_tab_page.dart +++ b/flutter/lib/desktop/pages/file_manager_tab_page.dart @@ -54,6 +54,7 @@ class _FileManagerTabPageState extends State { key: ValueKey(params['id']), id: params['id'], password: params['password'], + loginMethod: params['loginMethod'] ?? '', isSharedPassword: params['isSharedPassword'], tabController: tabController, forceRelay: params['forceRelay'], @@ -91,6 +92,7 @@ class _FileManagerTabPageState extends State { key: ValueKey(id), id: id, password: args['password'], + loginMethod: args['loginMethod'] ?? '', isSharedPassword: args['isSharedPassword'], tabController: tabController, forceRelay: args['forceRelay'], diff --git a/flutter/lib/desktop/pages/port_forward_page.dart b/flutter/lib/desktop/pages/port_forward_page.dart index 13dca0eaf..529c6f658 100644 --- a/flutter/lib/desktop/pages/port_forward_page.dart +++ b/flutter/lib/desktop/pages/port_forward_page.dart @@ -29,6 +29,7 @@ class PortForwardPage extends StatefulWidget { Key? key, required this.id, required this.password, + this.loginMethod = '', required this.tabController, required this.isRDP, required this.isSharedPassword, @@ -37,6 +38,7 @@ class PortForwardPage extends StatefulWidget { }) : super(key: key); final String id; final String? password; + final String loginMethod; final DesktopTabController tabController; final bool isRDP; final bool? forceRelay; @@ -69,6 +71,7 @@ class _PortForwardPageState extends State _ffi.start(widget.id, isPortForward: true, password: widget.password, + loginMethod: widget.loginMethod, isSharedPassword: widget.isSharedPassword, forceRelay: widget.forceRelay, connToken: widget.connToken, diff --git a/flutter/lib/desktop/pages/port_forward_tab_page.dart b/flutter/lib/desktop/pages/port_forward_tab_page.dart index 9d366bcb0..a0833bf02 100644 --- a/flutter/lib/desktop/pages/port_forward_tab_page.dart +++ b/flutter/lib/desktop/pages/port_forward_tab_page.dart @@ -44,6 +44,7 @@ class _PortForwardTabPageState extends State { key: ValueKey(params['id']), id: params['id'], password: params['password'], + loginMethod: params['loginMethod'] ?? '', isSharedPassword: params['isSharedPassword'], tabController: tabController, isRDP: isRDP, @@ -79,6 +80,7 @@ class _PortForwardTabPageState extends State { key: ValueKey(args['id']), id: id, password: args['password'], + loginMethod: args['loginMethod'] ?? '', isSharedPassword: args['isSharedPassword'], isRDP: isRDP, tabController: tabController, diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index ecd9080e9..15a6ce622 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -41,6 +41,7 @@ class RemotePage extends StatefulWidget { this.sessionId, this.tabWindowId, this.password, + this.loginMethod = '', this.display, this.displays, this.tabController, @@ -57,6 +58,7 @@ class RemotePage extends StatefulWidget { final int? display; final List? displays; final String? password; + final String loginMethod; final ToolbarState toolbarState; final String? switchUuid; final bool? forceRelay; @@ -180,6 +182,7 @@ class _RemotePageState extends State _ffi.start( widget.id, password: widget.password, + loginMethod: widget.loginMethod, isSharedPassword: widget.isSharedPassword, switchUuid: widget.switchUuid, forceRelay: widget.forceRelay, diff --git a/flutter/lib/desktop/pages/remote_tab_page.dart b/flutter/lib/desktop/pages/remote_tab_page.dart index 0b94a4916..acd46db55 100644 --- a/flutter/lib/desktop/pages/remote_tab_page.dart +++ b/flutter/lib/desktop/pages/remote_tab_page.dart @@ -97,6 +97,7 @@ class _ConnectionTabPageState extends State { display: display, displays: displays?.cast(), password: params['password'], + loginMethod: params['loginMethod'] ?? '', toolbarState: ToolbarState(), tabController: tabController, switchUuid: params['switch_uuid'], @@ -470,6 +471,7 @@ class _ConnectionTabPageState extends State { display: display, displays: displays?.cast(), password: args['password'], + loginMethod: args['loginMethod'] ?? '', toolbarState: ToolbarState(), tabController: tabController, switchUuid: switchUuid, diff --git a/flutter/lib/desktop/pages/terminal_connection_manager.dart b/flutter/lib/desktop/pages/terminal_connection_manager.dart index 91b8baa97..759e17e05 100644 --- a/flutter/lib/desktop/pages/terminal_connection_manager.dart +++ b/flutter/lib/desktop/pages/terminal_connection_manager.dart @@ -14,6 +14,7 @@ class TerminalConnectionManager { static FFI getConnection({ required String peerId, required String? password, + String loginMethod = '', required bool? isSharedPassword, required bool? forceRelay, required String? connToken, @@ -32,6 +33,7 @@ class TerminalConnectionManager { ffi.start( peerId, password: password, + loginMethod: loginMethod, isSharedPassword: isSharedPassword, forceRelay: forceRelay, connToken: connToken, diff --git a/flutter/lib/desktop/pages/terminal_page.dart b/flutter/lib/desktop/pages/terminal_page.dart index b6ea485a1..2cb09ab9b 100644 --- a/flutter/lib/desktop/pages/terminal_page.dart +++ b/flutter/lib/desktop/pages/terminal_page.dart @@ -13,6 +13,7 @@ class TerminalPage extends StatefulWidget { Key? key, required this.id, required this.password, + this.loginMethod = '', required this.tabController, required this.isSharedPassword, required this.terminalId, @@ -24,6 +25,7 @@ class TerminalPage extends StatefulWidget { }) : super(key: key); final String id; final String? password; + final String loginMethod; final DesktopTabController tabController; final bool? forceRelay; final bool? isSharedPassword; @@ -68,6 +70,7 @@ class _TerminalPageState extends State _ffi = TerminalConnectionManager.getConnection( peerId: widget.id, password: widget.password, + loginMethod: widget.loginMethod, isSharedPassword: widget.isSharedPassword, forceRelay: widget.forceRelay, connToken: widget.connToken, diff --git a/flutter/lib/desktop/pages/terminal_tab_page.dart b/flutter/lib/desktop/pages/terminal_tab_page.dart index 2612c9d35..3fa93fb9c 100644 --- a/flutter/lib/desktop/pages/terminal_tab_page.dart +++ b/flutter/lib/desktop/pages/terminal_tab_page.dart @@ -79,6 +79,7 @@ class _TerminalTabPageState extends State { peerId: params['id'], terminalId: terminalId, password: params['password'], + loginMethod: params['loginMethod'] ?? '', isSharedPassword: params['isSharedPassword'], forceRelay: params['forceRelay'], connToken: params['connToken'], @@ -89,6 +90,7 @@ class _TerminalTabPageState extends State { required String peerId, required int terminalId, String? password, + String loginMethod = '', bool? isSharedPassword, bool? forceRelay, String? connToken, @@ -114,6 +116,7 @@ class _TerminalTabPageState extends State { terminalId: terminalId, tabKey: tabKey, password: password, + loginMethod: loginMethod, isSharedPassword: isSharedPassword, tabController: tabController, forceRelay: forceRelay, @@ -514,6 +517,7 @@ class _TerminalTabPageState extends State { peerId: id, terminalId: terminalId, password: args['password'], + loginMethod: args['loginMethod'] ?? '', isSharedPassword: args['isSharedPassword'], forceRelay: args['forceRelay'], connToken: args['connToken'], @@ -713,6 +717,7 @@ class _TerminalTabPageState extends State { peerId: peerId, terminalId: newTerminalId, password: page.password, + loginMethod: page.loginMethod, isSharedPassword: page.isSharedPassword, forceRelay: page.forceRelay, connToken: page.connToken, diff --git a/flutter/lib/desktop/pages/view_camera_page.dart b/flutter/lib/desktop/pages/view_camera_page.dart index 6eb65b11d..3b87d915f 100644 --- a/flutter/lib/desktop/pages/view_camera_page.dart +++ b/flutter/lib/desktop/pages/view_camera_page.dart @@ -37,6 +37,7 @@ class ViewCameraPage extends StatefulWidget { this.sessionId, this.tabWindowId, this.password, + this.loginMethod = '', this.display, this.displays, this.tabController, @@ -53,6 +54,7 @@ class ViewCameraPage extends StatefulWidget { final int? display; final List? displays; final String? password; + final String loginMethod; final ToolbarState toolbarState; final bool? forceRelay; final bool? isSharedPassword; @@ -112,6 +114,7 @@ class _ViewCameraPageState extends State widget.id, isViewCamera: true, password: widget.password, + loginMethod: widget.loginMethod, isSharedPassword: widget.isSharedPassword, forceRelay: widget.forceRelay, tabWindowId: widget.tabWindowId, diff --git a/flutter/lib/desktop/pages/view_camera_tab_page.dart b/flutter/lib/desktop/pages/view_camera_tab_page.dart index 36fa623ff..b72a8674f 100644 --- a/flutter/lib/desktop/pages/view_camera_tab_page.dart +++ b/flutter/lib/desktop/pages/view_camera_tab_page.dart @@ -97,6 +97,7 @@ class _ViewCameraTabPageState extends State { display: display, displays: displays?.cast(), password: params['password'], + loginMethod: params['loginMethod'] ?? '', toolbarState: ToolbarState(), tabController: tabController, connToken: params['connToken'], @@ -433,6 +434,7 @@ class _ViewCameraTabPageState extends State { display: display, displays: displays?.cast(), password: args['password'], + loginMethod: args['loginMethod'] ?? '', toolbarState: ToolbarState(), tabController: tabController, connToken: args['connToken'], diff --git a/flutter/lib/desktop/widgets/material_mod_popup_menu.dart b/flutter/lib/desktop/widgets/material_mod_popup_menu.dart index 0e085a14e..19cd0d153 100644 --- a/flutter/lib/desktop/widgets/material_mod_popup_menu.dart +++ b/flutter/lib/desktop/widgets/material_mod_popup_menu.dart @@ -656,11 +656,13 @@ class _PopupMenuRouteLayout extends SingleChildLayoutDelegate { this.selectedItemIndex, this.textDirection, this.padding, - this.avoidBounds, - ); + this.avoidBounds, { + this.submenuAnchor, + }); // Rectangle of underlying button, relative to the overlay's dimensions. final RelativeRect position; + final Rect? submenuAnchor; // The sizes of each item are computed when the menu is laid out, and before // the route is laid out. @@ -735,6 +737,14 @@ class _PopupMenuRouteLayout extends SingleChildLayoutDelegate { DisplayFeatureSubScreen.subScreensInBounds( Offset.zero & size, avoidBounds); final Rect subScreen = _closestScreen(subScreens, originCenter); + final anchor = submenuAnchor; + if (anchor != null) { + final left = anchor.left - childSize.width; + if (wantedPosition.dx + childSize.width > + subScreen.right - _kMenuScreenPadding - padding.right) { + return _fitInsideScreen(subScreen, childSize, Offset(left, y)); + } + } return _fitInsideScreen(subScreen, childSize, wantedPosition); } @@ -781,6 +791,7 @@ class _PopupMenuRouteLayout extends SingleChildLayoutDelegate { assert(itemSizes.length == oldDelegate.itemSizes.length); return position != oldDelegate.position || + submenuAnchor != oldDelegate.submenuAnchor || selectedItemIndex != oldDelegate.selectedItemIndex || textDirection != oldDelegate.textDirection || !listEquals(itemSizes, oldDelegate.itemSizes) || @@ -792,6 +803,7 @@ class _PopupMenuRouteLayout extends SingleChildLayoutDelegate { class _PopupMenuRoute extends PopupRoute { _PopupMenuRoute({ required this.position, + this.submenuAnchor, required this.items, this.menuWrapper, this.initialValue, @@ -805,6 +817,7 @@ class _PopupMenuRoute extends PopupRoute { }) : itemSizes = List.filled(items.length, null); final RelativeRect position; + final Rect? submenuAnchor; final List> items; final MenuWrapper? menuWrapper; final List itemSizes; @@ -874,6 +887,7 @@ class _PopupMenuRoute extends PopupRoute { Directionality.of(context), mediaQuery.padding, _avoidBounds(mediaQuery), + submenuAnchor: submenuAnchor, ), child: capturedThemes.wrap(menu), ); @@ -1042,6 +1056,7 @@ class PopupMenu extends StatelessWidget { Future showMenu({ required BuildContext context, required RelativeRect position, + Rect? submenuAnchor, required List> items, MenuWrapper? menuWrapper, T? initialValue, @@ -1070,6 +1085,7 @@ Future showMenu({ Navigator.of(context, rootNavigator: useRootNavigator); return navigator.push(_PopupMenuRoute( position: position, + submenuAnchor: submenuAnchor, items: items, menuWrapper: menuWrapper, initialValue: initialValue, @@ -1352,6 +1368,9 @@ class PopupMenuButtonState extends State> { menuWrapper: widget.menuWrapper, initialValue: widget.initialValue, position: position, + submenuAnchor: widget.position == PopupMenuPosition.overSide + ? button.localToGlobal(Offset.zero, ancestor: overlay) & button.size + : null, shape: widget.shape ?? popupMenuTheme.shape, color: widget.color ?? popupMenuTheme.color, constraints: widget.constraints, diff --git a/flutter/lib/desktop/widgets/popup_menu.dart b/flutter/lib/desktop/widgets/popup_menu.dart index 28432a978..c66f2b499 100644 --- a/flutter/lib/desktop/widgets/popup_menu.dart +++ b/flutter/lib/desktop/widgets/popup_menu.dart @@ -15,6 +15,8 @@ class PopupMenuChildrenItem extends mod_menu.PopupMenuEntry { this.enabled, this.textStyle, this.onTap, + this.dismissParentOnCanceled = false, + this.useButtonLayout = false, this.position = mod_menu.PopupMenuPosition.overSide, this.offset = Offset.zero, required this.itemBuilder, @@ -27,6 +29,8 @@ class PopupMenuChildrenItem extends mod_menu.PopupMenuEntry { final EdgeInsets? padding; final RxBool? enabled; final void Function()? onTap; + final bool dismissParentOnCanceled; + final bool useButtonLayout; final List> Function(BuildContext) itemBuilder; final Widget child; @@ -69,20 +73,42 @@ class MyPopupMenuItemState> position: widget.position, offset: widget.offset, onSelected: handleTap, + onCanceled: widget.dismissParentOnCanceled + ? () { + if (ModalRoute.of(context)?.isCurrent == true) { + Navigator.pop(context); + } + } + : null, itemBuilder: widget.itemBuilder, padding: EdgeInsets.zero, - child: AnimatedDefaultTextStyle( - style: style, - duration: kThemeChangeDuration, - child: Container( - alignment: AlignmentDirectional.centerStart, - constraints: BoxConstraints( - minHeight: widget.height, maxHeight: widget.height), - padding: - widget.padding ?? const EdgeInsets.symmetric(horizontal: 16), - child: widget.child, - ), - ), + child: Builder(builder: (context) { + final child = AnimatedDefaultTextStyle( + style: style, + duration: kThemeChangeDuration, + child: Container( + alignment: AlignmentDirectional.centerStart, + constraints: BoxConstraints( + minHeight: widget.height, maxHeight: widget.height), + padding: widget.padding ?? + const EdgeInsets.symmetric(horizontal: 16), + child: widget.child, + ), + ); + if (!widget.useButtonLayout) return child; + final onPressed = enabled.value + ? () => context + .findAncestorStateOfType>() + ?.showButtonMenu() + : null; + // The popup trigger owns keyboard focus; retain the menu button sizing. + return ExcludeFocus( + child: TextButton( + onPressed: onPressed, + child: TextButton(onPressed: onPressed, child: child), + ), + ); + }), )); } } @@ -645,11 +671,15 @@ class MenuEntrySubMenu extends MenuEntryBase { final String text; final List> entries; final EdgeInsets? padding; + final bool dismissParentOnCanceled; + final double arrowScale; MenuEntrySubMenu({ required this.text, required this.entries, this.padding, + this.dismissParentOnCanceled = false, + this.arrowScale = MenuConfig.iconScale, RxBool? enabled, }) : super(enabled: enabled); @@ -662,13 +692,14 @@ class MenuEntrySubMenu extends MenuEntryBase { enabled: super.enabled, height: conf.height, padding: padding, + dismissParentOnCanceled: dismissParentOnCanceled, + useButtonLayout: true, position: mod_menu.PopupMenuPosition.overSide, itemBuilder: (BuildContext context) => entries .map((entry) => entry.build(context, conf)) .expand((i) => i) .toList(), child: Row(children: [ - const SizedBox(width: MenuConfig.midPadding), Obx(() => Text( text, style: super.enabled!.value @@ -678,10 +709,15 @@ class MenuEntrySubMenu extends MenuEntryBase { Expanded( child: Align( alignment: Alignment.centerRight, - child: Obx(() => Icon( - Icons.keyboard_arrow_right, - color: super.enabled!.value ? conf.commonColor : Colors.grey, - )), + child: Transform.scale( + scale: arrowScale, + child: Obx(() => Icon( + Icons.keyboard_arrow_right, + color: super.enabled!.value + ? Theme.of(context).unselectedWidgetColor + : disabledStyle().color, + )), + ), )) ]), ) diff --git a/flutter/lib/mobile/pages/file_manager_page.dart b/flutter/lib/mobile/pages/file_manager_page.dart index fa57fdbb2..ad9e7d97f 100644 --- a/flutter/lib/mobile/pages/file_manager_page.dart +++ b/flutter/lib/mobile/pages/file_manager_page.dart @@ -16,11 +16,13 @@ class FileManagerPage extends StatefulWidget { FileManagerPage( {Key? key, required this.id, + this.loginMethod = '', this.password, this.isSharedPassword, this.forceRelay}) : super(key: key); final String id; + final String loginMethod; final String? password; final bool? isSharedPassword; final bool? forceRelay; @@ -276,6 +278,7 @@ class _FileManagerPageState extends State { super.initState(); gFFI.start(widget.id, isFileTransfer: true, + loginMethod: widget.loginMethod, password: widget.password, isSharedPassword: widget.isSharedPassword, forceRelay: widget.forceRelay); diff --git a/flutter/lib/mobile/pages/home_page.dart b/flutter/lib/mobile/pages/home_page.dart index 651ec4f17..fbf882593 100644 --- a/flutter/lib/mobile/pages/home_page.dart +++ b/flutter/lib/mobile/pages/home_page.dart @@ -208,6 +208,7 @@ class WebHomePage extends StatelessWidget { bool isTerminal = false; String? id; String? password; + var loginMethod = ''; for (int i = 0; i < args.length; i++) { switch (args[i]) { case '--connect': @@ -240,6 +241,9 @@ class WebHomePage extends StatelessWidget { password = args[i + 1]; i++; break; + case '--request-approval': + loginMethod = 'request-approval'; + break; default: break; } @@ -249,6 +253,7 @@ class WebHomePage extends StatelessWidget { isFileTransfer: isFileTransfer, isViewCamera: isViewCamera, isTerminal: isTerminal, + loginMethod: loginMethod, password: password); } } diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 9da67ab8b..f41ee4808 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -71,12 +71,14 @@ class RemotePage extends StatefulWidget { RemotePage( {Key? key, required this.id, + this.loginMethod = '', this.password, this.isSharedPassword, this.forceRelay}) : super(key: key); final String id; + final String loginMethod; final String? password; final bool? isSharedPassword; final bool? forceRelay; @@ -123,6 +125,7 @@ class _RemotePageState extends State with WidgetsBindingObserver { gFFI.ffiModel.updateEventListener(sessionId, widget.id); gFFI.start( widget.id, + loginMethod: widget.loginMethod, password: widget.password, isSharedPassword: widget.isSharedPassword, forceRelay: widget.forceRelay, diff --git a/flutter/lib/mobile/pages/terminal_page.dart b/flutter/lib/mobile/pages/terminal_page.dart index ac2ca42f5..19d0a8414 100644 --- a/flutter/lib/mobile/pages/terminal_page.dart +++ b/flutter/lib/mobile/pages/terminal_page.dart @@ -71,8 +71,10 @@ class TerminalPage extends StatefulWidget { required this.isSharedPassword, this.forceRelay, this.connToken, + this.loginMethod = '', }) : super(key: key); final String id; + final String loginMethod; final String? password; final bool? forceRelay; final bool? isSharedPassword; @@ -141,6 +143,7 @@ class _TerminalPageState extends State // Use shared FFI instance from connection manager _ffi = TerminalConnectionManager.getConnection( peerId: widget.id, + loginMethod: widget.loginMethod, password: widget.password, isSharedPassword: widget.isSharedPassword, forceRelay: widget.forceRelay, diff --git a/flutter/lib/mobile/pages/view_camera_page.dart b/flutter/lib/mobile/pages/view_camera_page.dart index 1bedca19a..9dadcf9a5 100644 --- a/flutter/lib/mobile/pages/view_camera_page.dart +++ b/flutter/lib/mobile/pages/view_camera_page.dart @@ -40,12 +40,14 @@ class ViewCameraPage extends StatefulWidget { ViewCameraPage( {Key? key, required this.id, + this.loginMethod = '', this.password, this.isSharedPassword, this.forceRelay}) : super(key: key); final String id; + final String loginMethod; final String? password; final bool? isSharedPassword; final bool? forceRelay; @@ -90,6 +92,7 @@ class _ViewCameraPageState extends State gFFI.start( widget.id, isViewCamera: true, + loginMethod: widget.loginMethod, password: widget.password, isSharedPassword: widget.isSharedPassword, forceRelay: widget.forceRelay, diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 20e71f10d..c3e917b7c 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -920,9 +920,10 @@ class FfiModel with ChangeNotifier { enter2FaDialog(sessionId, dialogManager); } else if (type == 'input-password') { enterPasswordDialog(sessionId, dialogManager); - } else if (type == 'terminal-admin-login') { - enterUserLoginDialog( - sessionId, dialogManager, 'terminal-admin-login-tip'); + } else if (type == 'terminal-admin-login' || + type == 'terminal-admin-login-request-approval') { + enterUserLoginDialog(sessionId, dialogManager, 'terminal-admin-login-tip', + waitForAccept: type == 'terminal-admin-login-request-approval'); } else if (type == 'terminal-admin-login-password') { enterUserLoginAndPasswordDialog( sessionId, dialogManager, 'terminal-admin-login-tip'); @@ -4123,6 +4124,7 @@ class FFI { int? tabWindowId, int? display, List? displays, + String loginMethod = '', }) { closed = false; if (isMobile) mobileReset(); @@ -4168,6 +4170,7 @@ class FFI { password: password ?? '', isSharedPassword: isSharedPassword ?? false, connToken: connToken, + loginMethod: loginMethod, ); } else if (display != null) { if (displays == null) { diff --git a/flutter/lib/utils/multi_window_manager.dart b/flutter/lib/utils/multi_window_manager.dart index 9e26f8cf9..09bcc1a58 100644 --- a/flutter/lib/utils/multi_window_manager.dart +++ b/flutter/lib/utils/multi_window_manager.dart @@ -225,6 +225,7 @@ class RustDeskMultiWindowManager { String remoteId, List windows, { String? password, + String loginMethod = '', bool? forceRelay, String? switchUuid, bool? isRDP, @@ -249,6 +250,9 @@ class RustDeskMultiWindowManager { if (connToken != null) { params['connToken'] = connToken; } + if (loginMethod.isNotEmpty) { + params['loginMethod'] = loginMethod; + } final msg = jsonEncode(params); // separate window for file transfer is not supported @@ -270,6 +274,7 @@ class RustDeskMultiWindowManager { Future newRemoteDesktop( String remoteId, { String? password, + String loginMethod = '', bool? isSharedPassword, String? switchUuid, bool? forceRelay, @@ -280,6 +285,7 @@ class RustDeskMultiWindowManager { remoteId, _remoteDesktopWindows, password: password, + loginMethod: loginMethod, forceRelay: forceRelay, switchUuid: switchUuid, isSharedPassword: isSharedPassword, @@ -289,6 +295,7 @@ class RustDeskMultiWindowManager { Future newFileTransfer( String remoteId, { String? password, + String loginMethod = '', bool? isSharedPassword, bool? forceRelay, String? connToken, @@ -299,6 +306,7 @@ class RustDeskMultiWindowManager { remoteId, _fileTransferWindows, password: password, + loginMethod: loginMethod, forceRelay: forceRelay, isSharedPassword: isSharedPassword, connToken: connToken, @@ -308,6 +316,7 @@ class RustDeskMultiWindowManager { Future newViewCamera( String remoteId, { String? password, + String loginMethod = '', bool? isSharedPassword, String? switchUuid, bool? forceRelay, @@ -319,6 +328,7 @@ class RustDeskMultiWindowManager { remoteId, _viewCameraWindows, password: password, + loginMethod: loginMethod, forceRelay: forceRelay, switchUuid: switchUuid, isSharedPassword: isSharedPassword, @@ -330,6 +340,7 @@ class RustDeskMultiWindowManager { String remoteId, bool isRDP, { String? password, + String loginMethod = '', bool? isSharedPassword, bool? forceRelay, String? connToken, @@ -340,6 +351,7 @@ class RustDeskMultiWindowManager { remoteId, _portForwardWindows, password: password, + loginMethod: loginMethod, forceRelay: forceRelay, isRDP: isRDP, isSharedPassword: isSharedPassword, @@ -350,6 +362,7 @@ class RustDeskMultiWindowManager { Future newTerminal( String remoteId, { String? password, + String loginMethod = '', bool? isSharedPassword, bool? forceRelay, String? connToken, @@ -375,6 +388,9 @@ class RustDeskMultiWindowManager { "isSharedPassword": isSharedPassword, "connToken": connToken, }; + if (loginMethod.isNotEmpty) { + params['loginMethod'] = loginMethod; + } final msg = jsonEncode(params); // Always create a new window for terminal diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index bfde2985b..a2e7f4fa1 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -149,6 +149,7 @@ class RustdeskImpl { required String password, required bool isSharedPassword, String? connToken, + required String loginMethod, dynamic hint}) { return js.context.callMethod('setByName', [ 'session_add_sync', @@ -156,6 +157,7 @@ class RustdeskImpl { 'id': id, 'password': password, 'is_shared_password': isSharedPassword, + 'login_method': loginMethod, 'isFileTransfer': isFileTransfer, 'isViewCamera': isViewCamera, 'isTerminal': isTerminal @@ -1964,6 +1966,9 @@ class RustdeskImpl { required String key, required String param, dynamic hint}) { + if (key == 'login_method') { + return js.context.callMethod('getByName', ['login_method']); + } throw UnimplementedError("sessionGetCommonSync"); } diff --git a/flutter/test/request_approval_menu_test.dart b/flutter/test/request_approval_menu_test.dart new file mode 100644 index 000000000..68e2f2727 --- /dev/null +++ b/flutter/test/request_approval_menu_test.dart @@ -0,0 +1,297 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_hbb/common/widgets/request_approval_menu.dart'; +import 'package:flutter_hbb/desktop/widgets/material_mod_popup_menu.dart' + as mod_menu; +import 'package:flutter_hbb/desktop/widgets/popup_menu.dart'; + +const connectionTypes = [ + 'Connect', + 'Transfer file', + 'View camera', + 'Terminal', + 'Terminal (Run as administrator)', + 'TCP tunneling', + 'RDP', +]; + +Future openMenu(WidgetTester tester, List actions, + {ThemeData? theme, + EdgeInsets? padding, + bool includeRelay = false, + RelativeRect position = const RelativeRect.fromLTRB(20, 20, 780, 580)}) async { + MenuEntryButton entry(String title, {bool requestApproval = false}) => + MenuEntryButton( + childBuilder: (style) => Text(title, style: style), + proc: () => + actions.add(requestApproval ? '$title:request-approval' : title), + padding: padding, + dismissOnClicked: true, + ); + + await tester.pumpWidget(MaterialApp( + theme: theme, + home: Scaffold( + body: Builder(builder: (context) { + return TextButton( + onPressed: () => mod_menu.showMenu( + context: context, + position: position, + items: [ + entry('Connect'), + entry('Transfer file'), + entry('View camera'), + MenuEntryButton( + childBuilder: (style) => Row(children: [ + Text('RDP', style: style), + const Spacer(), + IconButton( + icon: const Icon(Icons.edit), + onPressed: () { + Navigator.pop(context); + actions.add('Edit RDP'); + }, + ), + ]), + proc: () => actions.add('RDP'), + padding: padding, + dismissOnClicked: true, + ), + RequestApprovalMenu( + title: 'Request approval', + padding: padding, + entries: [ + for (final title in connectionTypes) + entry(title, requestApproval: true), + ], + ), + if (includeRelay) + MenuEntrySwitchSync( + switchType: SwitchType.scheckbox, + text: 'Always connect via relay', + currentValue: false, + setter: (_) async {}, + padding: padding, + dismissOnClicked: true, + ), + entry('Rename'), + ] + .expand((entry) => entry.build(context, + const MenuConfig(commonColor: Colors.black, height: 20))) + .toList(), + ), + child: const Text('Open menu'), + ); + }), + ), + )); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open menu')); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('submenu prefers the left screen edge when neither side fits', + (tester) async { + tester.view.physicalSize = const Size(500, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await openMenu(tester, [], + position: const RelativeRect.fromLTRB(480, 20, 20, 780)); + await tester.tap(find.text('Request approval')); + await tester.pumpAndSettle(); + final submenu = tester.getRect(find.ancestor( + of: find.text('Connect').last, + matching: find.byType(mod_menu.PopupMenuItem))); + expect(submenu.left, closeTo(0, 0.01)); + expect(submenu.right, lessThanOrEqualTo(500)); + await tester.tapAt(const Offset(2, 2)); + await tester.pumpAndSettle(); + }); + + testWidgets('approval submenu opens beside its parent on either screen edge', + (tester) async { + tester.view.physicalSize = const Size(1200, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + for (final atRight in [false, true]) { + await openMenu(tester, [], + position: RelativeRect.fromLTRB(atRight ? 1180 : 20, 20, + atRight ? 20 : 1180, 780)); + final parent = tester.getRect(find.byType(PopupMenuChildrenItem)); + await tester.tap(find.text('Request approval')); + await tester.pumpAndSettle(); + final submenu = tester.getRect(find.ancestor( + of: find.text('Connect').last, + matching: find.byType(mod_menu.PopupMenuItem))); + if (atRight) { + expect(submenu.right, lessThanOrEqualTo(parent.left)); + } else { + expect(submenu.left, greaterThanOrEqualTo(parent.right)); + } + await tester.tapAt(const Offset(2, 2)); + await tester.pumpAndSettle(); + } + }); + + testWidgets('approval trigger has one Tab stop and opens with Enter', + (tester) async { + await openMenu(tester, []); + bool approvalHasFocus() => FocusManager.instance.primaryFocus?.context + ?.findAncestorWidgetOfExactType>() != null; + + for (var i = 0; i < 30 && !approvalHasFocus(); i++) { + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + } + expect(approvalHasFocus(), isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(approvalHasFocus(), isFalse, + reason: 'Tab must leave the approval row after one stop'); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(); + expect(approvalHasFocus(), isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(find.text('Terminal (Run as administrator)'), findsOneWidget); + }); + + testWidgets('tapping outside the approval submenu closes both menus', + (tester) async { + final actions = []; + await openMenu(tester, actions); + await tester.tap(find.text('Request approval')); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(2, 2)); + await tester.pumpAndSettle(); + expect(actions, isEmpty); + expect(find.text('Request approval'), findsNothing); + expect(find.text('Rename'), findsNothing); + expect(find.text('Open menu'), findsOneWidget); + }); + + testWidgets('approval text and arrow align with normal menu items', + (tester) async { + for (final (theme, padding) in [ + (ThemeData(), null), + ( + ThemeData( + useMaterial3: false, + platform: TargetPlatform.windows, + visualDensity: VisualDensity.compact), + const EdgeInsets.only(left: 12, right: 3) + ), + ( + ThemeData(useMaterial3: false), + const EdgeInsets.symmetric(horizontal: 12) + ), + ( + ThemeData( + useMaterial3: false, + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 20)), + )), + null + ), + ]) { + await openMenu(tester, [], + theme: theme, padding: padding, includeRelay: true); + expect(tester.getTopLeft(find.text('Request approval')).dx, + tester.getTopLeft(find.text('Connect')).dx); + expect(tester.getCenter(find.byIcon(Icons.keyboard_arrow_right)).dx, + closeTo(tester.getCenter(find.byType(Checkbox)).dx, 0.01)); + await tester.tap(find.text('Rename')); + await tester.pumpAndSettle(); + } + }); + + testWidgets('normal connections and RDP editing remain direct actions', + (tester) async { + for (final title in [ + 'Connect', + 'Transfer file', + 'View camera', + 'RDP', + 'Edit RDP' + ]) { + final actions = []; + await openMenu(tester, actions); + expect(find.byIcon(Icons.keyboard_arrow_right), findsOneWidget); + final left = tester.getTopLeft(find.text('Rename')).dx; + for (final row in ['Connect', 'Transfer file', 'View camera', 'RDP']) { + expect(tester.getTopLeft(find.text(row)).dx, left); + } + await tester.tap( + title == 'Edit RDP' ? find.byIcon(Icons.edit) : find.text(title)); + await tester.pumpAndSettle(); + expect(actions, [title]); + expect(find.text('Request approval'), findsNothing); + expect(find.text('Rename'), findsNothing); + } + }); + + testWidgets( + 'one approval menu offers all connection types and closes both menus', + (tester) async { + for (var i = 0; i < connectionTypes.length; i++) { + final actions = []; + await openMenu(tester, actions); + await tester.tap(i.isEven + ? find.text('Request approval') + : find.byIcon(Icons.keyboard_arrow_right)); + await tester.pumpAndSettle(); + expect(actions, isEmpty); + for (final title in connectionTypes) { + expect(find.text(title), findsWidgets); + } + await tester.tap(find.text(connectionTypes[i]).last); + await tester.pumpAndSettle(); + expect(actions, ['${connectionTypes[i]}:request-approval']); + expect(find.text('Request approval'), findsNothing); + expect(find.text('Rename'), findsNothing); + } + }, + variant: TargetPlatformVariant({ + TargetPlatform.android, + TargetPlatform.iOS, + TargetPlatform.linux, + TargetPlatform.macOS, + TargetPlatform.windows, + })); + + testWidgets('the whole approval row opens the submenu on a narrow screen', + (tester) async { + tester.view.physicalSize = const Size(360, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + for (final corner in [ + Alignment.topLeft, + Alignment.topRight, + Alignment.bottomLeft, + Alignment.bottomRight, + ]) { + final actions = []; + await openMenu(tester, actions); + final row = tester.getRect(find.byType(PopupMenuChildrenItem)); + await tester.tapAt(corner.withinRect(row.deflate(2))); + await tester.pumpAndSettle(); + expect(actions, isEmpty); + expect(find.text('Terminal (Run as administrator)'), findsOneWidget); + await tester.tap(find.text('Connect').last); + await tester.pumpAndSettle(); + expect(actions, ['Connect:request-approval']); + expect(find.text('Rename'), findsNothing); + } + }, + variant: + TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); +} diff --git a/flutter/test/uri_login_method_test.dart b/flutter/test/uri_login_method_test.dart new file mode 100644 index 000000000..0601a39c1 --- /dev/null +++ b/flutter/test/uri_login_method_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('request-approval links preserve their connection type', () { + for (final type in [ + 'connect', + 'play', + 'file-transfer', + 'view-camera', + 'port-forward', + 'rdp', + 'terminal', + 'terminal-admin', + ]) { + expect( + urlLinkToCmdArgs( + Uri.parse('rustdesk://$type/123456789?request-approval=true')), + ['--$type', '123456789', '--request-approval'], + ); + } + expect( + urlLinkToCmdArgs(Uri.parse('rustdesk://123456789?request-approval=true')), + ['--connect', '123456789', '--request-approval'], + ); + }); + + test('omitted and empty passwords retain automatic login', () { + expect(urlLinkToCmdArgs(Uri.parse('rustdesk://123456789')), + ['--connect', '123456789']); + for (final query in ['password=', 'password=&request-approval=false']) { + expect(urlLinkToCmdArgs(Uri.parse('rustdesk://123456789?$query')), + ['--connect', '123456789', '--password', '']); + } + }); + + test('request-approval ignores an explicit password in either order', () { + for (final query in [ + 'password=provided&request-approval=true', + 'request-approval=true&password=provided', + ]) { + expect(urlLinkToCmdArgs(Uri.parse('rustdesk://123456789?$query')), + ['--connect', '123456789', '--request-approval']); + } + }); +} diff --git a/src/client.rs b/src/client.rs index 597dc863a..30338df87 100644 --- a/src/client.rs +++ b/src/client.rs @@ -93,6 +93,8 @@ use crate::ui_session_interface::SessionPermissionConfig; pub use super::lang::*; +mod login_method; + #[cfg(not(target_os = "linux"))] mod audio_playback; #[cfg(target_os = "windows")] @@ -2834,6 +2836,7 @@ pub struct LoginConfigHandler { hash: Hash, password: Vec, // remember password for reconnect pub remember: bool, + login_method: login_method::LoginMethod, config: PeerConfig, pub port_forward: (String, i32), /// This login's `multiplex`, filled with `port_forward` under the turn @@ -2911,6 +2914,7 @@ impl LoginConfigHandler { adapter_luid: Option, shared_password: Option, conn_token: Option, + login_method: &str, ) { let mut id = id; if id.contains("@") { @@ -2951,6 +2955,10 @@ impl LoginConfigHandler { self.id = id; self.conn_type = conn_type; + self.login_method = Default::default(); + if !login_method.is_empty() && !self.set_login_method(login_method) { + log::warn!("Failed to initialize login method: {}", login_method); + } let config = self.load_config(); self.remember = !config.password.is_empty(); self.config = config; @@ -3671,7 +3679,7 @@ impl LoginConfigHandler { let password0 = config.password.clone(); let remember = self.remember; let hash = self.hash.clone(); - if remember { + if remember && !self.skips_saved_password() { // remember is true: use PeerConfig password or ui login // not sync shared password to recent if !password.is_empty() @@ -3681,7 +3689,7 @@ impl LoginConfigHandler { config.password = password.clone(); log::debug!("remember password of {}", self.id); } - } else { + } else if !self.skips_saved_password() { if self.password_source.is_personal_ab(&password) { // sync personal ab password to recent automatically config.password = password.clone(); @@ -3708,7 +3716,8 @@ impl LoginConfigHandler { #[cfg(feature = "flutter")] { // sync connected password to personal ab automatically if it is not shared password - if !config.password.is_empty() + if !self.skips_saved_password() + && !config.password.is_empty() && !self.password_source.is_shared_ab(&password, &hash) && !self.password_source.is_personal_ab(&password) { @@ -3860,6 +3869,9 @@ impl LoginConfigHandler { avatar, ..Default::default() }; + if self.skips_saved_password() { + lr.password.clear(); + } match self.conn_type { ConnType::FILE_TRANSFER => lr.set_file_transfer(FileTransfer { dir: self.get_remote_dir(), @@ -4733,10 +4745,14 @@ pub async fn handle_hash( lc.write().unwrap().password = password.clone(); + let request_approval = lc.read().unwrap().skips_saved_password(); + let is_terminal_admin = lc.read().unwrap().is_terminal_admin; let is_terminal = lc.read().unwrap().conn_type.eq(&ConnType::TERMINAL); if is_terminal && is_terminal_admin { - if password.is_empty() { + if request_approval { + interface.msgbox("terminal-admin-login-request-approval", "", "", ""); + } else if password.is_empty() { interface.msgbox("terminal-admin-login-password", "", "", ""); } else { interface.msgbox("terminal-admin-login", "", "", ""); @@ -4747,7 +4763,9 @@ pub async fn handle_hash( let password = if password.is_empty() { // login without password, the remote side can click accept - interface.msgbox("input-password", "Password Required", "", ""); + if !request_approval { + interface.msgbox("input-password", "Password Required", "", ""); + } Vec::new() } else { let mut hasher = Sha256::new(); @@ -4756,6 +4774,15 @@ pub async fn handle_hash( hasher.finalize()[..].into() }; + if request_approval { + interface.msgbox( + "wait-remote-accept-nook", + "Prompt", + "Please wait for the remote side to accept your session request...", + "", + ); + } + if let Err(error) = send_login( lc.clone(), String::new(), diff --git a/src/client/login_method.rs b/src/client/login_method.rs new file mode 100644 index 000000000..4d74dc6f1 --- /dev/null +++ b/src/client/login_method.rs @@ -0,0 +1,274 @@ +use super::LoginConfigHandler; + +#[derive(Default, PartialEq, Eq)] +pub(super) enum LoginMethod { + #[default] + Automatic, + RequestApproval, +} + +impl LoginConfigHandler { + pub(crate) fn set_login_method(&mut self, method: &str) -> bool { + self.login_method = match method { + "request-approval" => LoginMethod::RequestApproval, + _ => return false, + }; + true + } + + pub(crate) fn skips_saved_password(&self) -> bool { + self.login_method != LoginMethod::Automatic + } + + pub(crate) fn get_login_method(&self) -> &'static str { + match self.login_method { + LoginMethod::Automatic => "", + LoginMethod::RequestApproval => "request-approval", + } + } +} + +#[cfg(all(test, feature = "flutter"))] +mod tests { + use crate::{client, flutter::FlutterHandler, ui_session_interface::Session}; + use base::message_proto::{Hash, Message, PeerInfo}; + use hbb_common::{ + config::PeerConfig, protobuf::Message as _, rendezvous_proto::ConnType, tcp::FramedStream, + tokio, Stream, + }; + + async fn connection() -> (Stream, Stream) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let client = tokio::net::TcpStream::connect(addr).await.unwrap(); + let (socket, addr) = listener.accept().await.unwrap(); + ( + Stream::Tcp(FramedStream::from(client, listener.local_addr().unwrap())), + Stream::Tcp(FramedStream::from(socket, addr)), + ) + } + + async fn received_password(peer: &mut Stream) -> Vec { + let bytes = peer.next_timeout(3000).await.unwrap().unwrap(); + Message::parse_from_bytes(&bytes) + .unwrap() + .login_request() + .password + .to_vec() + } + + #[test] + fn session_creation_initializes_login_method() { + let remote_id = hbb_common::config::LocalConfig::get_remote_id(); + for method in ["", "request-approval"] { + let session_id = uuid::Uuid::new_v4(); + let result = crate::flutter_ffi::session_add_sync( + session_id, + format!("login-method-init-{}", session_id), + false, + false, + false, + false, + false, + String::new(), + false, + String::new(), + false, + None, + method.to_owned(), + ); + let monitor_session_id = uuid::Uuid::new_v4(); + let monitor_result = crate::flutter_ffi::session_add_existed_sync( + format!("login-method-init-{}", session_id), + monitor_session_id, + vec![0], + false, + ); + let methods = [session_id, monitor_session_id].map(|id| { + crate::flutter_ffi::session_get_common_sync( + id, + "login_method".to_owned(), + String::new(), + ) + .0 + }); + crate::flutter::sessions::remove_session_by_session_id(&monitor_session_id); + let session = crate::flutter::sessions::remove_session_by_session_id(&session_id); + hbb_common::config::LocalConfig::set_remote_id(&remote_id); + assert!(result.0.is_empty(), "{}", result.0); + assert!(monitor_result.0.is_empty(), "{}", monitor_result.0); + assert_eq!(methods, [Some(method.to_owned()), Some(method.to_owned())]); + let session = session.unwrap(); + let request = session + .lc + .read() + .unwrap() + .create_login_msg(String::new(), String::new(), vec![1; 32]); + assert_eq!( + request.login_request().password.is_empty(), + method == "request-approval" + ); + } + } + + #[tokio::test] + async fn configured_passwords_are_skipped_only_for_the_selected_connection() { + for conn_type in [ + ConnType::DEFAULT_CONN, + ConnType::FILE_TRANSFER, + ConnType::VIEW_CAMERA, + ConnType::PORT_FORWARD, + ConnType::RDP, + ConnType::TERMINAL, + ] { + for skip in [false, true] { + let session = Session::::default(); + { + let mut lc = session.lc.write().unwrap(); + lc.id = format!("skip-saved-password-test-{}", uuid::Uuid::new_v4()); + lc.conn_type = conn_type; + lc.config.password = vec![1; 32]; + lc.password = vec![2; 32]; + lc.shared_password = Some("shared password".into()); + let token = lc.get_conn_token(); + if skip { + assert!(lc.set_login_method("request-approval")); + } + assert_eq!(lc.get_conn_token(), token); + let request = lc.create_login_msg(String::new(), String::new(), vec![4; 32]); + assert_eq!(request.login_request().password.is_empty(), skip); + } + let (mut stream, mut peer) = connection().await; + assert!( + client::handle_hash( + session.lc.clone(), + "preset password", + Hash { + salt: "salt".into(), + challenge: "challenge".into(), + ..Default::default() + }, + &session, + &mut Default::default(), + &mut stream, + ) + .await + ); + let password = received_password(&mut peer).await; + assert_eq!(password.is_empty(), skip); + } + } + } + + #[tokio::test] + async fn admin_terminal_waits_for_os_credentials_before_requesting_approval() { + let session = Session::::default(); + { + let mut lc = session.lc.write().unwrap(); + lc.conn_type = ConnType::TERMINAL; + lc.is_terminal_admin = true; + assert!(lc.set_login_method("request-approval")); + } + let (mut stream, mut peer) = connection().await; + let mut trust = Default::default(); + assert!( + client::handle_hash( + session.lc.clone(), + "preset password", + Hash::default(), + &session, + &mut trust, + &mut stream, + ) + .await + ); + assert!(peer.next_timeout(50).await.is_none()); + client::handle_login_from_ui( + session.lc.clone(), + "administrator".into(), + "os password".into(), + String::new(), + false, + &mut trust, + &mut stream, + ) + .await + .unwrap(); + let bytes = peer.next_timeout(3000).await.unwrap().unwrap(); + let message = Message::parse_from_bytes(&bytes).unwrap(); + let login = message.login_request(); + assert!(login.password.is_empty()); + assert_eq!(login.os_login.username, "administrator"); + assert_eq!(login.os_login.password, "os password"); + } + + #[test] + fn request_approval_does_not_save_personal_ab_password() { + for request_approval in [false, true] { + let mut lc = client::LoginConfigHandler::default(); + lc.id = format!("request-approval-ab-password-test-{}", uuid::Uuid::new_v4()); + lc.password = vec![3; 32]; + lc.password_source = client::PasswordSource::PersonalAb(lc.password.clone()); + if request_approval { + assert!(lc.set_login_method("request-approval")); + } + lc.handle_peer_info(&PeerInfo::default()); + let saved = PeerConfig::load(&lc.id).password; + PeerConfig::remove(&lc.id); + assert_eq!( + saved, + if request_approval { + Vec::new() + } else { + lc.password + } + ); + } + } + + #[tokio::test] + async fn request_approval_preserves_saved_password() { + let (mut stream, mut peer) = connection().await; + let id = format!("skip-saved-password-test-{}", uuid::Uuid::new_v4()); + let mut config = PeerConfig::default(); + config.password = vec![3; 32]; + config.store(&id); + let session = Session::::default(); + { + let mut lc = session.lc.write().unwrap(); + lc.initialize( + id.clone(), + ConnType::DEFAULT_CONN, + None, + false, + None, + None, + None, + "", + ); + assert!(!lc.set_login_method("password")); + assert!(lc.set_login_method("request-approval")); + } + assert!( + client::handle_hash( + session.lc.clone(), + "", + Hash::default(), + &session, + &mut Default::default(), + &mut stream, + ) + .await + ); + let first = received_password(&mut peer).await; + assert!(first.is_empty()); + session + .lc + .write() + .unwrap() + .handle_peer_info(&PeerInfo::default()); + let saved = PeerConfig::load(&id).password; + PeerConfig::remove(&id); + assert_eq!(saved, config.password); + } +} diff --git a/src/core_main.rs b/src/core_main.rs index 19a26703a..0f62d1e59 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -73,6 +73,7 @@ pub fn core_main() -> Option { "--view-camera", "--port-forward", "--terminal", + "--terminal-admin", "--rdp", ] .contains(&arg.as_str()) @@ -763,7 +764,7 @@ fn core_main_invoke_new_connection(mut args: std::env::Args) -> Option { + | "--terminal" | "--terminal-admin" | "--rdp" => { authority = Some((&arg.to_string()[2..]).to_owned()); id = args.next(); } @@ -775,6 +776,9 @@ fn core_main_invoke_new_connection(mut args: std::env::Args) -> Option { param_array.push(format!("relay=true")); } + "--request-approval" => { + param_array.push("request-approval=true".to_owned()); + } // inner "--switch_uuid" => { if let Some(switch_uuid) = args.next() { diff --git a/src/flutter.rs b/src/flutter.rs index 8c393d4dd..31e6582e6 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -1316,6 +1316,7 @@ pub fn session_add( password: String, is_shared_password: bool, conn_token: Option, + login_method: &str, ) -> ResultType { let conn_type = if is_file_transfer { ConnType::FILE_TRANSFER @@ -1376,6 +1377,7 @@ pub fn session_add( get_adapter_luid(), shared_password, conn_token, + login_method, ); let session = Arc::new(session.clone()); diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 8473e38fa..0d6ca0ab5 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -160,6 +160,7 @@ pub fn session_add_sync( password: String, is_shared_password: bool, conn_token: Option, + login_method: String, ) -> SyncReturn { let add_res = session_add( &session_id, @@ -174,6 +175,7 @@ pub fn session_add_sync( password, is_shared_password, conn_token, + &login_method, ); // We can't put the remove call together with `std::env::var("IS_TERMINAL_ADMIN")`. // Because there are some `bail!` in `session_add()`, we must make sure `IS_TERMINAL_ADMIN` is removed at last. @@ -3053,6 +3055,8 @@ pub fn session_get_common( if let Some(s) = sessions::get_session_by_session_id(&session_id) { let v = if key == "is_screenshot_supported" { s.is_screenshot_supported().to_string() + } else if key == "login_method" { + s.lc.read().unwrap().get_login_method().to_owned() } else { "".to_owned() }; diff --git a/src/lang/ar.rs b/src/lang/ar.rs index 6aeef5187..dea58836c 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "مدة الثقة (بالأيام)"), ("Renew trust", "تجديد الثقة"), ("install-directory-not-empty-tip", "المجلد المحدد ليس فارغًا. قد يؤدي التثبيت إلى حذف البيانات الموجودة. يُنصح باختيار مجلد آخر."), + ("Request approval", "طلب الموافقة"), ].iter().cloned().collect(); } diff --git a/src/lang/az.rs b/src/lang/az.rs index dad6634a0..b1ed8b384 100644 --- a/src/lang/az.rs +++ b/src/lang/az.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Etibar müddəti (gün)"), ("Renew trust", "Etibarı yenilə"), ("install-directory-not-empty-tip", "Seçilmiş qovluq boş deyil. Quraşdırma mövcud məlumatları silə bilər. Başqa qovluq seçməyiniz tövsiyə olunur."), + ("Request approval", "Təsdiq istə"), ].iter().cloned().collect(); } diff --git a/src/lang/be.rs b/src/lang/be.rs index 1a6d0f38f..062e20544 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Тэрмін даверу (у днях)"), ("Renew trust", "Падоўжыць давер"), ("install-directory-not-empty-tip", "Выбраная папка не пустая. Усталёўванне можа выдаліць наяўныя даныя. Лепш выберыце іншую папку."), + ("Request approval", "Запытаць дазвол"), ].iter().cloned().collect(); } diff --git a/src/lang/bg.rs b/src/lang/bg.rs index dee17f910..5712ab0de 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Срок на доверие (дни)"), ("Renew trust", "Подновяване на доверието"), ("install-directory-not-empty-tip", "Избраната папка не е празна. Инсталацията може да изтрие съществуващите данни. Препоръчително е да изберете друга папка."), + ("Request approval", "Поискай одобрение"), ].iter().cloned().collect(); } diff --git a/src/lang/ca.rs b/src/lang/ca.rs index ca35cccd6..d5a538306 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Durada de la confiança (dies)"), ("Renew trust", "Renova la confiança"), ("install-directory-not-empty-tip", "La carpeta seleccionada no és buida. La instal·lació pot suprimir les dades existents. Considereu triar una altra carpeta."), + ("Request approval", "Sol·licita aprovació"), ].iter().cloned().collect(); } diff --git a/src/lang/cn.rs b/src/lang/cn.rs index 3ca59f160..086ad0f42 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "信任有效期(天)"), ("Renew trust", "延长信任有效期"), ("install-directory-not-empty-tip", "所选文件夹不为空。安装可能删除现有数据。建议选择其他文件夹。"), + ("Request approval", "请求批准"), ].iter().cloned().collect(); } diff --git a/src/lang/cs.rs b/src/lang/cs.rs index 4eccfbf04..eb2f392fa 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Doba důvěry (dny)"), ("Renew trust", "Obnovit důvěru"), ("install-directory-not-empty-tip", "Vybraná složka není prázdná. Instalace může odstranit existující data. Zvažte výběr jiné složky."), + ("Request approval", "Požádat o schválení"), ].iter().cloned().collect(); } diff --git a/src/lang/da.rs b/src/lang/da.rs index d65dd8dd8..94854c262 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Tillidsperiode (dage)"), ("Renew trust", "Forny tillid"), ("install-directory-not-empty-tip", "Den valgte mappe er ikke tom. Installationen kan slette eksisterende data. Overvej at vælge en anden mappe."), + ("Request approval", "Anmod om godkendelse"), ].iter().cloned().collect(); } diff --git a/src/lang/de.rs b/src/lang/de.rs index e71e52b0b..5d20db7d8 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Vertrauensdauer (Tage)"), ("Renew trust", "Vertrauen erneuern"), ("install-directory-not-empty-tip", "Der ausgewählte Ordner ist nicht leer. Bei der Installation können vorhandene Daten gelöscht werden. Erwägen Sie, einen anderen Ordner auszuwählen."), + ("Request approval", "Genehmigung anfordern"), ].iter().cloned().collect(); } diff --git a/src/lang/el.rs b/src/lang/el.rs index 83e82f1e3..898284e0b 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Διάρκεια εμπιστοσύνης (ημέρες)"), ("Renew trust", "Ανανέωση εμπιστοσύνης"), ("install-directory-not-empty-tip", "Ο επιλεγμένος φάκελος δεν είναι κενός. Η εγκατάσταση ενδέχεται να διαγράψει υπάρχοντα δεδομένα. Εξετάστε το ενδεχόμενο να επιλέξετε διαφορετικό φάκελο."), + ("Request approval", "Αίτημα έγκρισης"), ].iter().cloned().collect(); } diff --git a/src/lang/eo.rs b/src/lang/eo.rs index 259975165..2cf9591d8 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Daŭro de fido (tagoj)"), ("Renew trust", "Renovigi fidon"), ("install-directory-not-empty-tip", "La elektita dosierujo ne estas malplena. La instalado povas forigi ekzistantajn datumojn. Konsideru elekti alian dosierujon."), + ("Request approval", "Peti aprobon"), ].iter().cloned().collect(); } diff --git a/src/lang/es.rs b/src/lang/es.rs index 5b6d1d48b..d951c2e92 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Duración de la confianza (días)"), ("Renew trust", "Renovar confianza"), ("install-directory-not-empty-tip", "La carpeta seleccionada no está vacía. La instalación puede eliminar los datos existentes. Considera elegir otra carpeta."), + ("Request approval", "Solicitar aprobación"), ].iter().cloned().collect(); } diff --git a/src/lang/et.rs b/src/lang/et.rs index 0b82562c2..33316521c 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Usalduse kestus (päevades)"), ("Renew trust", "Uuenda usaldust"), ("install-directory-not-empty-tip", "Valitud kaust ei ole tühi. Paigaldamine võib olemasolevad andmed kustutada. Kaalu teise kausta valimist."), + ("Request approval", "Küsi heakskiitu"), ].iter().cloned().collect(); } diff --git a/src/lang/eu.rs b/src/lang/eu.rs index 6eab8f185..4cc8e3834 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Konfiantzaren iraupena (egunak)"), ("Renew trust", "Berritu konfiantza"), ("install-directory-not-empty-tip", "Hautatutako karpeta ez dago hutsik. Instalazioak lehendik dauden datuak ezaba ditzake. Beste karpeta bat aukeratzea gomendatzen da."), + ("Request approval", "Eskatu onespena"), ].iter().cloned().collect(); } diff --git a/src/lang/fa.rs b/src/lang/fa.rs index 7fb8d150e..7a3dc90bb 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "مدت اعتماد (روز)"), ("Renew trust", "تمدید اعتماد"), ("install-directory-not-empty-tip", "پوشه انتخاب‌شده خالی نیست. نصب ممکن است داده‌های موجود را حذف کند. بهتر است پوشه دیگری انتخاب کنید."), + ("Request approval", "درخواست تأیید"), ].iter().cloned().collect(); } diff --git a/src/lang/fi.rs b/src/lang/fi.rs index ef23d4310..c789e55a6 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Luottamuksen kesto (päivää)"), ("Renew trust", "Jatka luottamusta"), ("install-directory-not-empty-tip", "Valittu kansio ei ole tyhjä. Asennus voi poistaa olemassa olevia tietoja. Harkitse toisen kansion valitsemista."), + ("Request approval", "Pyydä hyväksyntää"), ].iter().cloned().collect(); } diff --git a/src/lang/fr.rs b/src/lang/fr.rs index a7053407a..44e3ad027 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Durée de confiance (jours)"), ("Renew trust", "Renouveler la confiance"), ("install-directory-not-empty-tip", "Le dossier sélectionné n’est pas vide. L’installation peut supprimer des données existantes. Envisagez de choisir un autre dossier."), + ("Request approval", "Demander l'approbation"), ].iter().cloned().collect(); } diff --git a/src/lang/ge.rs b/src/lang/ge.rs index 563e1941c..dab4cb3e3 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "ნდობის ხანგრძლივობა (დღე)"), ("Renew trust", "ნდობის განახლება"), ("install-directory-not-empty-tip", "არჩეული საქაღალდე ცარიელი არ არის. ინსტალაციამ შეიძლება წაშალოს არსებული მონაცემები. რეკომენდებულია სხვა საქაღალდის არჩევა."), + ("Request approval", "დამტკიცების მოთხოვნა"), ].iter().cloned().collect(); } diff --git a/src/lang/gl.rs b/src/lang/gl.rs index 3c21b8121..967386cde 100644 --- a/src/lang/gl.rs +++ b/src/lang/gl.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Duración da confianza (días)"), ("Renew trust", "Renovar a confianza"), ("install-directory-not-empty-tip", "O cartafol seleccionado non está baleiro. A instalación pode eliminar os datos existentes. Considera escoller outro cartafol."), + ("Request approval", "Solicitar aprobación"), ].iter().cloned().collect(); } diff --git a/src/lang/gu.rs b/src/lang/gu.rs index 81bb91bef..a661a75ac 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "વિશ્વાસ અવધિ (દિવસો)"), ("Renew trust", "વિશ્વાસ નવીકરણ કરો"), ("install-directory-not-empty-tip", "પસંદ કરેલ ફોલ્ડર ખાલી નથી. ઇન્સ્ટોલેશન હાલનો ડેટા કાઢી નાખી શકે છે. બીજું ફોલ્ડર પસંદ કરવાનું વિચારો."), + ("Request approval", "મંજૂરીની વિનંતી કરો"), ].iter().cloned().collect(); } diff --git a/src/lang/he.rs b/src/lang/he.rs index 5dc319f0a..1d61c5ec5 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "משך האמון (ימים)"), ("Renew trust", "חידוש אמון"), ("install-directory-not-empty-tip", "התיקייה שנבחרה אינה ריקה. ההתקנה עלולה למחוק נתונים קיימים. מומלץ לבחור תיקייה אחרת."), + ("Request approval", "בקש אישור"), ].iter().cloned().collect(); } diff --git a/src/lang/hi.rs b/src/lang/hi.rs index a624e7b11..b479b276b 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "विश्वास अवधि (दिन)"), ("Renew trust", "विश्वास नवीनीकृत करें"), ("install-directory-not-empty-tip", "चयनित फ़ोल्डर खाली नहीं है। इंस्टॉलेशन से मौजूदा डेटा हट सकता है। कोई दूसरा फ़ोल्डर चुनने पर विचार करें।"), + ("Request approval", "स्वीकृति का अनुरोध करें"), ].iter().cloned().collect(); } diff --git a/src/lang/hr.rs b/src/lang/hr.rs index 9022614b1..d8aa60d0f 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Trajanje povjerenja (dani)"), ("Renew trust", "Obnovi povjerenje"), ("install-directory-not-empty-tip", "Odabrana mapa nije prazna. Instalacija može izbrisati postojeće podatke. Razmislite o odabiru druge mape."), + ("Request approval", "Zatraži odobrenje"), ].iter().cloned().collect(); } diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 57bba916b..661602d5f 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Megbízhatóság időtartama (nap)"), ("Renew trust", "Megbízhatóság megújítása"), ("install-directory-not-empty-tip", "A kiválasztott mappa nem üres. A telepítés törölheti a meglévő adatokat. Fontolja meg egy másik mappa kiválasztását."), + ("Request approval", "Jóváhagyás kérése"), ].iter().cloned().collect(); } diff --git a/src/lang/id.rs b/src/lang/id.rs index 82f9452b2..a55d05241 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Durasi kepercayaan (hari)"), ("Renew trust", "Perbarui kepercayaan"), ("install-directory-not-empty-tip", "Folder yang dipilih tidak kosong. Instalasi dapat menghapus data yang ada. Pertimbangkan untuk memilih folder lain."), + ("Request approval", "Minta persetujuan"), ].iter().cloned().collect(); } diff --git a/src/lang/it.rs b/src/lang/it.rs index f05500b09..c6843c200 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Durata attendibilità (giorni)"), ("Renew trust", "Rinnova attendibilità"), ("install-directory-not-empty-tip", "La cartella selezionata non è vuota. L'installazione potrebbe eliminare i dati esistenti. Valuta la possibilità di scegliere una cartella diversa."), + ("Request approval", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ja.rs b/src/lang/ja.rs index e75a4fb11..3366c17ab 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "信頼期間 (日)"), ("Renew trust", "信頼を更新"), ("install-directory-not-empty-tip", "選択したフォルダーは空ではありません。インストールにより既存のデータが削除される可能性があります。別のフォルダーを選択することをおすすめします。"), + ("Request approval", "承認を要求"), ].iter().cloned().collect(); } diff --git a/src/lang/ko.rs b/src/lang/ko.rs index 061c3dd54..cf5650ef3 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "신뢰 유지 기간 (일)"), ("Renew trust", "신뢰 갱신"), ("install-directory-not-empty-tip", "선택한 폴더가 비어 있지 않습니다. 설치 시 기존 데이터가 삭제될 수 있습니다. 다른 폴더를 선택하는 것이 좋습니다."), + ("Request approval", "승인 요청"), ].iter().cloned().collect(); } diff --git a/src/lang/kz.rs b/src/lang/kz.rs index 6249cf9ee..e1b2cc627 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Сенім мерзімі (күн)"), ("Renew trust", "Сенімді жаңарту"), ("install-directory-not-empty-tip", "Таңдалған бума бос емес. Орнату бар деректерді жоюы мүмкін. Басқа буманы таңдаған жөн."), + ("Request approval", "Мақұлдауды сұрау"), ].iter().cloned().collect(); } diff --git a/src/lang/lt.rs b/src/lang/lt.rs index 81e345206..8145decbd 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Pasitikėjimo trukmė (dienomis)"), ("Renew trust", "Atnaujinti pasitikėjimą"), ("install-directory-not-empty-tip", "Pasirinktas aplankas nėra tuščias. Diegimas gali ištrinti esamus duomenis. Apsvarstykite galimybę pasirinkti kitą aplanką."), + ("Request approval", "Prašyti patvirtinimo"), ].iter().cloned().collect(); } diff --git a/src/lang/lv.rs b/src/lang/lv.rs index c96666a48..e3acabcda 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Uzticēšanās ilgums (dienas)"), ("Renew trust", "Atjaunot uzticēšanos"), ("install-directory-not-empty-tip", "Izvēlētā mape nav tukša. Instalēšana var izdzēst esošos datus. Apsveriet iespēju izvēlēties citu mapi."), + ("Request approval", "Pieprasīt apstiprinājumu"), ].iter().cloned().collect(); } diff --git a/src/lang/ml.rs b/src/lang/ml.rs index e7012ed39..d97ae1903 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "വിശ്വാസ കാലാവധി (ദിവസം)"), ("Renew trust", "വിശ്വാസം പുതുക്കുക"), ("install-directory-not-empty-tip", "തിരഞ്ഞെടുത്ത ഫോൾഡർ ശൂന്യമല്ല. ഇൻസ്റ്റാളേഷൻ നിലവിലുള്ള ഡാറ്റ ഇല്ലാതാക്കിയേക്കാം. മറ്റൊരു ഫോൾഡർ തിരഞ്ഞെടുക്കുന്നത് പരിഗണിക്കുക."), + ("Request approval", "അംഗീകാരം അഭ്യർത്ഥിക്കുക"), ].iter().cloned().collect(); } diff --git a/src/lang/nb.rs b/src/lang/nb.rs index 628065f15..ddf1956ec 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Klareringsperiode (dager)"), ("Renew trust", "Forny klarering"), ("install-directory-not-empty-tip", "Den valgte mappen er ikke tom. Installasjonen kan slette eksisterende data. Vurder å velge en annen mappe."), + ("Request approval", "Be om godkjenning"), ].iter().cloned().collect(); } diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 205e74c6e..7a96b0640 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Vertrouwensperiode (dagen)"), ("Renew trust", "Vertrouwen hernieuwen"), ("install-directory-not-empty-tip", "De geselecteerde map is niet leeg. De installatie kan bestaande gegevens verwijderen. Overweeg een andere map te kiezen."), + ("Request approval", "Goedkeuring aanvragen"), ].iter().cloned().collect(); } diff --git a/src/lang/pl.rs b/src/lang/pl.rs index f31d2ccf9..112fe8bdb 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Okres zaufania (dni)"), ("Renew trust", "Odnów zaufanie"), ("install-directory-not-empty-tip", "Wybrany folder nie jest pusty. Instalacja może usunąć istniejące dane. Rozważ wybranie innego folderu."), + ("Request approval", "Poproś o zatwierdzenie"), ].iter().cloned().collect(); } diff --git a/src/lang/pt_BR.rs b/src/lang/pt_BR.rs index 2ce922331..4e27b7091 100644 --- a/src/lang/pt_BR.rs +++ b/src/lang/pt_BR.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Duração da confiança (dias)"), ("Renew trust", "Renovar confiança"), ("install-directory-not-empty-tip", "A pasta selecionada não está vazia. A instalação pode excluir dados existentes. Considere escolher outra pasta."), + ("Request approval", "Solicitar aprovação"), ].iter().cloned().collect(); } diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index 7f2335232..03a74da13 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Duração da confiança (dias)"), ("Renew trust", "Renovar confiança"), ("install-directory-not-empty-tip", "A pasta selecionada não está vazia. A instalação pode eliminar dados existentes. Considere escolher outra pasta."), + ("Request approval", "Solicitar aprovação"), ].iter().cloned().collect(); } diff --git a/src/lang/ro.rs b/src/lang/ro.rs index a2651cbca..95d2098a9 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Durata încrederii (zile)"), ("Renew trust", "Reînnoiește încrederea"), ("install-directory-not-empty-tip", "Folderul selectat nu este gol. Instalarea poate șterge datele existente. Ia în considerare alegerea altui folder."), + ("Request approval", "Solicită aprobarea"), ].iter().cloned().collect(); } diff --git a/src/lang/ru.rs b/src/lang/ru.rs index c42f43b86..7c11aa84f 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Срок доверия (в днях)"), ("Renew trust", "Продлить доверие"), ("install-directory-not-empty-tip", "Выбранная папка не пуста. При установке существующие данные могут быть удалены. Рекомендуется выбрать другую папку."), + ("Request approval", "Запросить одобрение"), ].iter().cloned().collect(); } diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 8a2ee69d8..6d44b38ed 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Durada de sa fidùtzia (dies)"), ("Renew trust", "Annoa sa fidùtzia"), ("install-directory-not-empty-tip", "Sa cartella seletzionada non est bòida. S'installatzione diat pòdere iscantzellare sos datos chi bi sunt. Ti cussigiamus de isseberare un'àtera cartella."), + ("Request approval", "Pedi s'aprovatzione"), ].iter().cloned().collect(); } diff --git a/src/lang/sk.rs b/src/lang/sk.rs index 2e75015d5..8456b1d48 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Doba dôvery (dni)"), ("Renew trust", "Obnoviť dôveru"), ("install-directory-not-empty-tip", "Vybraný priečinok nie je prázdny. Inštalácia môže odstrániť existujúce údaje. Zvážte výber iného priečinka."), + ("Request approval", "Požiadať o schválenie"), ].iter().cloned().collect(); } diff --git a/src/lang/sl.rs b/src/lang/sl.rs index c3d7f58de..e1cffebe0 100644 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Trajanje zaupanja (dni)"), ("Renew trust", "Obnovi zaupanje"), ("install-directory-not-empty-tip", "Izbrana mapa ni prazna. Namestitev lahko izbriše obstoječe podatke. Razmislite o izbiri druge mape."), + ("Request approval", "Zahtevaj odobritev"), ].iter().cloned().collect(); } diff --git a/src/lang/sq.rs b/src/lang/sq.rs index 052e85a4e..ba7f83759 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Kohëzgjatja e besimit (ditë)"), ("Renew trust", "Rinovo besimin"), ("install-directory-not-empty-tip", "Dosja e zgjedhur nuk është bosh. Instalimi mund të fshijë të dhënat ekzistuese. Konsideroni të zgjidhni një dosje tjetër."), + ("Request approval", "Kërko miratim"), ].iter().cloned().collect(); } diff --git a/src/lang/sr.rs b/src/lang/sr.rs index 31996ef95..f34e05651 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Trajanje poverenja (dani)"), ("Renew trust", "Obnovi poverenje"), ("install-directory-not-empty-tip", "Izabrani direktorijum nije prazan. Instalacija može da obriše postojeće podatke. Razmislite o izboru drugog direktorijuma."), + ("Request approval", "Zatraži odobrenje"), ].iter().cloned().collect(); } diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 1d2c2a731..cd76d76cb 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Förtroendeperiod (dagar)"), ("Renew trust", "Förnya förtroende"), ("install-directory-not-empty-tip", "Den valda mappen är inte tom. Installationen kan radera befintliga data. Överväg att välja en annan mapp."), + ("Request approval", "Begär godkännande"), ].iter().cloned().collect(); } diff --git a/src/lang/ta.rs b/src/lang/ta.rs index c6c1f3ada..e1e8ccf0b 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "நம்பிக்கைக் காலம் (நாட்கள்)"), ("Renew trust", "நம்பிக்கையைப் புதுப்பி"), ("install-directory-not-empty-tip", "தேர்ந்தெடுக்கப்பட்ட கோப்புறை காலியாக இல்லை. நிறுவல் ஏற்கனவே உள்ள தரவை நீக்கக்கூடும். வேறு கோப்புறையைத் தேர்ந்தெடுப்பது நல்லது."), + ("Request approval", "ஒப்புதல் கோரவும்"), ].iter().cloned().collect(); } diff --git a/src/lang/template.rs b/src/lang/template.rs index 567d694a8..4bffc873a 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", ""), ("Renew trust", ""), ("install-directory-not-empty-tip", ""), + ("Request approval", ""), ].iter().cloned().collect(); } diff --git a/src/lang/th.rs b/src/lang/th.rs index 9fbb5ab6e..c8184c353 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "ระยะเวลาการเชื่อถือ (วัน)"), ("Renew trust", "ต่ออายุการเชื่อถือ"), ("install-directory-not-empty-tip", "โฟลเดอร์ที่เลือกไม่ว่างเปล่า การติดตั้งอาจลบข้อมูลที่มีอยู่ ควรพิจารณาเลือกโฟลเดอร์อื่น"), + ("Request approval", "ขออนุมัติ"), ].iter().cloned().collect(); } diff --git a/src/lang/tr.rs b/src/lang/tr.rs index 4f5af81bf..0d578ea7f 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Güven süresi (gün)"), ("Renew trust", "Güveni yenile"), ("install-directory-not-empty-tip", "Seçilen klasör boş değil. Kurulum mevcut verileri silebilir. Farklı bir klasör seçmeyi düşünün."), + ("Request approval", "Onay iste"), ].iter().cloned().collect(); } diff --git a/src/lang/tw.rs b/src/lang/tw.rs index 6e1921001..8313f0898 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "信任有效期(天)"), ("Renew trust", "延長信任期限"), ("install-directory-not-empty-tip", "所選資料夾不是空的。安裝可能刪除現有資料。建議選擇其他資料夾。"), + ("Request approval", "請求批准"), ].iter().cloned().collect(); } diff --git a/src/lang/uk.rs b/src/lang/uk.rs index 8bae61255..70689e835 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Термін довіри (у днях)"), ("Renew trust", "Подовжити довіру"), ("install-directory-not-empty-tip", "Вибрана тека не порожня. Встановлення може видалити наявні дані. Радимо вибрати іншу теку."), + ("Request approval", "Запросити схвалення"), ].iter().cloned().collect(); } diff --git a/src/lang/ur.rs b/src/lang/ur.rs index e36f84ee1..725c0cd5f 100644 --- a/src/lang/ur.rs +++ b/src/lang/ur.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "اعتماد کی مدت (دن)"), ("Renew trust", "اعتماد کی تجدید کریں"), ("install-directory-not-empty-tip", "منتخب فولڈر خالی نہیں ہے۔ انسٹالیشن موجودہ ڈیٹا کو حذف کر سکتی ہے۔ کوئی دوسرا فولڈر منتخب کرنے پر غور کریں۔"), + ("Request approval", "منظوری کی درخواست کریں"), ].iter().cloned().collect(); } diff --git a/src/lang/vi.rs b/src/lang/vi.rs index 4d82f4b41..1a17321fa 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -824,5 +824,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Trust duration (days)", "Thời hạn tin cậy (ngày)"), ("Renew trust", "Gia hạn tin cậy"), ("install-directory-not-empty-tip", "Thư mục đã chọn không trống. Quá trình cài đặt có thể xóa dữ liệu hiện có. Hãy cân nhắc chọn một thư mục khác."), + ("Request approval", "Yêu cầu phê duyệt"), ].iter().cloned().collect(); } diff --git a/src/ui/remote.rs b/src/ui/remote.rs index 1bdf90a55..56c09e268 100644 --- a/src/ui/remote.rs +++ b/src/ui/remote.rs @@ -621,7 +621,7 @@ impl SciterSession { .lc .write() .unwrap() - .initialize(id, conn_type, None, force_relay, None, None, None); + .initialize(id, conn_type, None, force_relay, None, None, None, ""); Self(session) }