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) }