diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 000a1cb54..3f0abd43f 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -4,6 +4,7 @@ const double kDesktopRemoteTabBarHeight = 28.0; const String kAppTypeMain = "main"; const String kAppTypeDesktopRemote = "remote"; const String kAppTypeDesktopFileTransfer = "file transfer"; +const String kAppTypeDesktopPortForward = "port forward"; const String kTabLabelHomePage = "Home"; const String kTabLabelSettingPage = "Settings"; diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index 12f17c95e..632177e29 100644 --- a/flutter/lib/desktop/pages/desktop_home_page.dart +++ b/flutter/lib/desktop/pages/desktop_home_page.dart @@ -806,6 +806,8 @@ Future loginDialog() async { var userNameMsg = ""; String pass = ""; var passMsg = ""; + var userContontroller = TextEditingController(text: userName); + var pwdController = TextEditingController(text: pass); var isInProgress = false; var completer = Completer(); @@ -833,13 +835,10 @@ Future loginDialog() async { ), Expanded( child: TextField( - onChanged: (s) { - userName = s; - }, decoration: InputDecoration( border: OutlineInputBorder(), errorText: userNameMsg.isNotEmpty ? userNameMsg : null), - controller: TextEditingController(text: userName), + controller: userContontroller, ), ), ], @@ -859,13 +858,10 @@ Future loginDialog() async { Expanded( child: TextField( obscureText: true, - onChanged: (s) { - pass = s; - }, decoration: InputDecoration( border: OutlineInputBorder(), errorText: passMsg.isNotEmpty ? passMsg : null), - controller: TextEditingController(text: pass), + controller: pwdController, ), ), ], @@ -896,8 +892,8 @@ Future loginDialog() async { isInProgress = false; }); }; - userName = userName; - pass = pass; + userName = userContontroller.text; + pass = pwdController.text; if (userName.isEmpty) { userNameMsg = translate("Username missed"); cancel(); diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index 4f86974f1..120f8bc7a 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -1025,7 +1025,6 @@ class _ComboBox extends StatelessWidget { void changeServer() async { Map oldOptions = jsonDecode(await bind.mainGetOptions()); - print("${oldOptions}"); String idServer = oldOptions['custom-rendezvous-server'] ?? ""; var idServerMsg = ""; String relayServer = oldOptions['relay-server'] ?? ""; @@ -1033,6 +1032,10 @@ void changeServer() async { String apiServer = oldOptions['api-server'] ?? ""; var apiServerMsg = ""; var key = oldOptions['key'] ?? ""; + var idController = TextEditingController(text: idServer); + var relayController = TextEditingController(text: relayServer); + var apiController = TextEditingController(text: apiServer); + var keyController = TextEditingController(text: key); var isInProgress = false; gFFI.dialogManager.show((setState, close) { @@ -1057,13 +1060,10 @@ void changeServer() async { ), Expanded( child: TextField( - onChanged: (s) { - idServer = s; - }, decoration: InputDecoration( border: OutlineInputBorder(), errorText: idServerMsg.isNotEmpty ? idServerMsg : null), - controller: TextEditingController(text: idServer), + controller: idController, ), ), ], @@ -1082,14 +1082,11 @@ void changeServer() async { ), Expanded( child: TextField( - onChanged: (s) { - relayServer = s; - }, decoration: InputDecoration( border: OutlineInputBorder(), errorText: relayServerMsg.isNotEmpty ? relayServerMsg : null), - controller: TextEditingController(text: relayServer), + controller: relayController, ), ), ], @@ -1108,14 +1105,11 @@ void changeServer() async { ), Expanded( child: TextField( - onChanged: (s) { - apiServer = s; - }, decoration: InputDecoration( border: OutlineInputBorder(), errorText: apiServerMsg.isNotEmpty ? apiServerMsg : null), - controller: TextEditingController(text: apiServer), + controller: apiController, ), ), ], @@ -1134,13 +1128,10 @@ void changeServer() async { ), Expanded( child: TextField( - onChanged: (s) { - key = s; - }, decoration: InputDecoration( border: OutlineInputBorder(), ), - controller: TextEditingController(text: key), + controller: keyController, ), ), ], @@ -1171,10 +1162,10 @@ void changeServer() async { isInProgress = false; }); }; - idServer = idServer.trim(); - relayServer = relayServer.trim(); - apiServer = apiServer.trim(); - key = key.trim(); + idServer = idController.text.trim(); + relayServer = relayController.text.trim(); + apiServer = apiController.text.trim().toLowerCase(); + key = keyController.text.trim(); if (idServer.isNotEmpty) { idServerMsg = translate( @@ -1230,6 +1221,7 @@ void changeWhiteList() async { Map oldOptions = jsonDecode(await bind.mainGetOptions()); var newWhiteList = ((oldOptions['whitelist'] ?? "") as String).split(','); var newWhiteListField = newWhiteList.join('\n'); + var controller = TextEditingController(text: newWhiteListField); var msg = ""; var isInProgress = false; gFFI.dialogManager.show((setState, close) { @@ -1246,15 +1238,12 @@ void changeWhiteList() async { children: [ Expanded( child: TextField( - onChanged: (s) { - newWhiteListField = s; - }, maxLines: null, decoration: InputDecoration( border: OutlineInputBorder(), errorText: msg.isEmpty ? null : translate(msg), ), - controller: TextEditingController(text: newWhiteListField), + controller: controller, ), ), ], @@ -1277,7 +1266,7 @@ void changeWhiteList() async { msg = ""; isInProgress = true; }); - newWhiteListField = newWhiteListField.trim(); + newWhiteListField = controller.text.trim(); var newWhiteList = ""; if (newWhiteListField.isEmpty) { // pass @@ -1319,6 +1308,9 @@ void changeSocks5Proxy() async { username = socks[1]; password = socks[2]; } + var proxyController = TextEditingController(text: proxy); + var userController = TextEditingController(text: username); + var pwdController = TextEditingController(text: password); var isInProgress = false; gFFI.dialogManager.show((setState, close) { @@ -1343,13 +1335,10 @@ void changeSocks5Proxy() async { ), Expanded( child: TextField( - onChanged: (s) { - proxy = s; - }, decoration: InputDecoration( border: OutlineInputBorder(), errorText: proxyMsg.isNotEmpty ? proxyMsg : null), - controller: TextEditingController(text: proxy), + controller: proxyController, ), ), ], @@ -1368,13 +1357,10 @@ void changeSocks5Proxy() async { ), Expanded( child: TextField( - onChanged: (s) { - username = s; - }, decoration: InputDecoration( border: OutlineInputBorder(), ), - controller: TextEditingController(text: username), + controller: userController, ), ), ], @@ -1393,13 +1379,10 @@ void changeSocks5Proxy() async { ), Expanded( child: TextField( - onChanged: (s) { - password = s; - }, decoration: InputDecoration( border: OutlineInputBorder(), ), - controller: TextEditingController(text: password), + controller: pwdController, ), ), ], @@ -1428,9 +1411,9 @@ void changeSocks5Proxy() async { isInProgress = false; }); }; - proxy = proxy.trim(); - username = username.trim(); - password = password.trim(); + proxy = proxyController.text.trim(); + username = userController.text.trim(); + password = pwdController.text.trim(); if (proxy.isNotEmpty) { proxyMsg = diff --git a/flutter/lib/desktop/pages/port_forward_page.dart b/flutter/lib/desktop/pages/port_forward_page.dart new file mode 100644 index 000000000..b83761181 --- /dev/null +++ b/flutter/lib/desktop/pages/port_forward_page.dart @@ -0,0 +1,348 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; +import 'package:get/get.dart'; +import 'package:wakelock/wakelock.dart'; + +const double _kColumn1Width = 30; +const double _kColumn4Width = 100; +const double _kRowHeight = 50; +const double _kTextLeftMargin = 20; + +class _PortForward { + int localPort; + String remoteHost; + int remotePort; + + _PortForward.fromJson(List json) + : localPort = json[0] as int, + remoteHost = json[1] as String, + remotePort = json[2] as int; +} + +class PortForwardPage extends StatefulWidget { + const PortForwardPage({Key? key, required this.id, required this.isRDP}) + : super(key: key); + final String id; + final bool isRDP; + + @override + State createState() => _PortForwardPageState(); +} + +class _PortForwardPageState extends State + with AutomaticKeepAliveClientMixin { + final bool isRdp = false; + final TextEditingController localPortController = TextEditingController(); + final TextEditingController remoteHostController = TextEditingController(); + final TextEditingController remotePortController = TextEditingController(); + RxList<_PortForward> pfs = RxList.empty(growable: true); + late FFI _ffi; + + @override + void initState() { + super.initState(); + _ffi = FFI(); + // _ffi.connect(widget.id, isPortForward: true); + Get.put(_ffi, tag: 'pf_${widget.id}'); + if (!Platform.isLinux) { + Wakelock.enable(); + } + print("init success with id ${widget.id}"); + } + + @override + void dispose() { + _ffi.close(); + _ffi.dialogManager.dismissAll(); + if (!Platform.isLinux) { + Wakelock.disable(); + } + Get.delete(tag: 'pf_${widget.id}'); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return Scaffold( + backgroundColor: MyTheme.color(context).grayBg, + body: FutureBuilder(future: () async { + if (!isRdp) { + refreshTunnelConfig(); + } + }(), builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + return Container( + decoration: BoxDecoration( + border: Border.all( + width: 20, color: MyTheme.color(context).grayBg!)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + buildPrompt(context), + Flexible( + child: Container( + decoration: BoxDecoration( + color: MyTheme.color(context).bg, + border: Border.all(width: 1, color: MyTheme.border)), + child: + widget.isRDP ? buildRdp(context) : buildTunnel(context), + ), + ), + ], + ), + ); + } + return const Offstage(); + }), + ); + } + + buildPrompt(BuildContext context) { + return Obx(() => Offstage( + offstage: pfs.isEmpty && !widget.isRDP, + child: Container( + height: 45, + color: const Color(0xFF007F00), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + translate('Listening ...'), + style: const TextStyle(fontSize: 16, color: Colors.white), + ), + Text( + translate('not_close_tcp_tip'), + style: const TextStyle( + fontSize: 10, color: Color(0xFFDDDDDD), height: 1.2), + ) + ])).marginOnly(bottom: 8), + )); + } + + buildTunnel(BuildContext context) { + text(String lable) => Expanded( + child: Text(translate(lable)).marginOnly(left: _kTextLeftMargin)); + + return Theme( + data: Theme.of(context) + .copyWith(backgroundColor: MyTheme.color(context).bg), + child: Obx(() => ListView.builder( + itemCount: pfs.length + 2, + itemBuilder: ((context, index) { + if (index == 0) { + return Container( + height: 25, + color: MyTheme.color(context).grayBg, + child: Row(children: [ + text('Local Port'), + const SizedBox(width: _kColumn1Width), + text('Remote Host'), + text('Remote Port'), + SizedBox( + width: _kColumn4Width, child: Text(translate('Action'))) + ]), + ); + } else if (index == 1) { + return buildTunnelAddRow(context); + } else { + return buildTunnelDataRow(context, pfs[index - 2], index - 2); + } + }))), + ); + } + + buildTunnelAddRow(BuildContext context) { + var portInputFormatter = [ + FilteringTextInputFormatter.allow(RegExp( + r'^([0-9]|[1-9]\d|[1-9]\d{2}|[1-9]\d{3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])$')) + ]; + + return Container( + height: _kRowHeight, + decoration: BoxDecoration(color: MyTheme.color(context).bg), + child: Row(children: [ + buildTunnelInputCell(context, + controller: localPortController, + inputFormatters: portInputFormatter), + const SizedBox( + width: _kColumn1Width, child: Icon(Icons.arrow_forward_sharp)), + buildTunnelInputCell(context, + controller: remoteHostController, hint: 'localhost'), + buildTunnelInputCell(context, + controller: remotePortController, + inputFormatters: portInputFormatter), + SizedBox( + width: _kColumn4Width, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + elevation: 0, side: const BorderSide(color: MyTheme.border)), + onPressed: () async { + int? localPort = int.tryParse(localPortController.text); + int? remotePort = int.tryParse(remotePortController.text); + if (localPort != null && + remotePort != null && + (remoteHostController.text.isEmpty || + remoteHostController.text.trim().isNotEmpty)) { + await bind.mainAddPortForward( + id: widget.id, + localPort: localPort, + remoteHost: remoteHostController.text.trim().isEmpty + ? 'localhost' + : remoteHostController.text.trim(), + remotePort: remotePort); + localPortController.clear(); + remoteHostController.clear(); + remotePortController.clear(); + refreshTunnelConfig(); + } + }, + child: Text( + translate('Add'), + ), + ).marginAll(10), + ), + ]), + ); + } + + buildTunnelInputCell(BuildContext context, + {required TextEditingController controller, + List? inputFormatters, + String? hint}) { + return Expanded( + child: TextField( + controller: controller, + inputFormatters: inputFormatters, + cursorColor: MyTheme.color(context).text, + cursorHeight: 20, + cursorWidth: 1, + decoration: InputDecoration( + border: OutlineInputBorder( + borderSide: BorderSide(color: MyTheme.color(context).border!)), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: MyTheme.color(context).border!)), + fillColor: MyTheme.color(context).bg, + contentPadding: const EdgeInsets.all(10), + hintText: hint, + hintStyle: TextStyle( + color: MyTheme.color(context).placeholder, fontSize: 16)), + style: TextStyle(color: MyTheme.color(context).text, fontSize: 16), + ).marginAll(10), + ); + } + + Widget buildTunnelDataRow(BuildContext context, _PortForward pf, int index) { + text(String lable) => Expanded( + child: Text(lable, style: const TextStyle(fontSize: 20)) + .marginOnly(left: _kTextLeftMargin)); + + return Container( + height: _kRowHeight, + decoration: BoxDecoration( + color: index % 2 == 0 + ? isDarkTheme() + ? const Color(0xFF202020) + : const Color(0xFFF4F5F6) + : MyTheme.color(context).bg), + child: Row(children: [ + text(pf.localPort.toString()), + const SizedBox(width: _kColumn1Width), + text(pf.remoteHost), + text(pf.remotePort.toString()), + SizedBox( + width: _kColumn4Width, + child: IconButton( + icon: const Icon(Icons.close), + onPressed: () async { + await bind.mainRemovePortForward( + id: widget.id, localPort: pf.localPort); + refreshTunnelConfig(); + }, + ), + ), + ]), + ); + } + + void refreshTunnelConfig() async { + String peer = await bind.mainGetPeer(id: widget.id); + Map config = jsonDecode(peer); + List infos = config['port_forwards'] as List; + List<_PortForward> result = List.empty(growable: true); + for (var e in infos) { + result.add(_PortForward.fromJson(e)); + } + pfs.value = result; + } + + buildRdp(BuildContext context) { + text1(String lable) => + Expanded(child: Text(lable).marginOnly(left: _kTextLeftMargin)); + text2(String lable) => Expanded( + child: Text( + lable, + style: TextStyle(fontSize: 20), + ).marginOnly(left: _kTextLeftMargin)); + return Theme( + data: Theme.of(context) + .copyWith(backgroundColor: MyTheme.color(context).bg), + child: ListView.builder( + itemCount: 2, + itemBuilder: ((context, index) { + if (index == 0) { + return Container( + height: 25, + color: MyTheme.color(context).grayBg, + child: Row(children: [ + text1('Local Port'), + const SizedBox(width: _kColumn1Width), + text1('Remote Host'), + text1('Remote Port'), + ]), + ); + } else { + return Container( + height: _kRowHeight, + decoration: BoxDecoration(color: MyTheme.color(context).bg), + child: Row(children: [ + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: SizedBox( + width: 120, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + elevation: 0, + side: const BorderSide(color: MyTheme.border)), + onPressed: () {}, + child: Text( + translate('New RDP'), + style: TextStyle( + fontWeight: FontWeight.w300, fontSize: 14), + ), + ).marginSymmetric(vertical: 10), + ).marginOnly(left: 20), + ), + ), + const SizedBox( + width: _kColumn1Width, + child: Icon(Icons.arrow_forward_sharp)), + text2('localhost'), + text2('RDP'), + ]), + ); + } + })), + ); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/flutter/lib/desktop/pages/port_forward_tab_page.dart b/flutter/lib/desktop/pages/port_forward_tab_page.dart new file mode 100644 index 000000000..28825b75a --- /dev/null +++ b/flutter/lib/desktop/pages/port_forward_tab_page.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; + +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/desktop/pages/port_forward_page.dart'; +import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/utils/multi_window_manager.dart'; +import 'package:get/get.dart'; + +class PortForwardTabPage extends StatefulWidget { + final Map params; + + const PortForwardTabPage({Key? key, required this.params}) : super(key: key); + + @override + State createState() => _PortForwardTabPageState(params); +} + +class _PortForwardTabPageState extends State { + final tabController = Get.put(DesktopTabController()); + + static final IconData selectedIcon = Icons.forward_sharp; + static final IconData unselectedIcon = Icons.forward_outlined; + + _PortForwardTabPageState(Map params) { + tabController.add(TabInfo( + key: params['id'] + params['isRDP'].toString(), + label: params['id'], + selectedIcon: selectedIcon, + unselectedIcon: unselectedIcon, + page: PortForwardPage( + key: ValueKey(params['id']), + id: params['id'], + isRDP: params['isRDP'], + ))); + } + + @override + void initState() { + super.initState(); + + tabController.onRemove = (_, id) => onRemoveId(id); + + rustDeskWinManager.setMethodHandler((call, fromWindowId) async { + print( + "call ${call.method} with args ${call.arguments} from window ${fromWindowId}"); + // for simplify, just replace connectionId + if (call.method == "new_port_forward") { + final args = jsonDecode(call.arguments); + final id = args['id']; + final isRDP = args['isRDP']; + window_on_top(windowId()); + tabController.add(TabInfo( + key: id, + label: id, + selectedIcon: selectedIcon, + unselectedIcon: unselectedIcon, + page: PortForwardPage(id: id, isRDP: isRDP))); + } else if (call.method == "onDestroy") { + tabController.state.value.tabs.forEach((tab) { + print("executing onDestroy hook, closing ${tab.label}}"); + final tag = tab.label; + ffi(tag).close().then((_) { + Get.delete(tag: tag); + }); + }); + Get.back(); + } + }); + } + + @override + Widget build(BuildContext context) { + final theme = isDarkTheme() ? TarBarTheme.dark() : TarBarTheme.light(); + return SubWindowDragToResizeArea( + windowId: windowId(), + child: Container( + decoration: BoxDecoration( + border: Border.all(color: MyTheme.color(context).border!)), + child: Scaffold( + backgroundColor: MyTheme.color(context).bg, + body: DesktopTab( + controller: tabController, + theme: theme, + isMainWindow: false, + tail: AddButton( + theme: theme, + ).paddingOnly(left: 10), + )), + ), + ); + } + + void onRemoveId(String id) { + ffi("pf_$id").close(); + if (tabController.state.value.tabs.length == 0) { + WindowController.fromWindowId(windowId()).close(); + } + } + + int windowId() { + return widget.params["windowId"]; + } +} diff --git a/flutter/lib/desktop/screen/desktop_port_forward_screen.dart b/flutter/lib/desktop/screen/desktop_port_forward_screen.dart new file mode 100644 index 000000000..c7c163a57 --- /dev/null +++ b/flutter/lib/desktop/screen/desktop_port_forward_screen.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/desktop/pages/port_forward_tab_page.dart'; +import 'package:provider/provider.dart'; + +/// multi-tab file port forward screen +class DesktopPortForwardScreen extends StatelessWidget { + final Map params; + + const DesktopPortForwardScreen({Key? key, required this.params}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: gFFI.ffiModel), + ], + child: Scaffold( + body: PortForwardTabPage( + params: params, + ), + ), + ); + } +} diff --git a/flutter/lib/desktop/widgets/peercard_widget.dart b/flutter/lib/desktop/widgets/peercard_widget.dart index 433ca9284..4db43398a 100644 --- a/flutter/lib/desktop/widgets/peercard_widget.dart +++ b/flutter/lib/desktop/widgets/peercard_widget.dart @@ -1,5 +1,6 @@ import 'package:contextmenu/contextmenu.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hbb/utils/multi_window_manager.dart'; import 'package:get/get.dart'; @@ -284,11 +285,20 @@ class _PeerCardState extends State<_PeerCard> /// Connect to a peer with [id]. /// If [isFileTransfer], starts a session only for file transfer. - void _connect(String id, {bool isFileTransfer = false}) async { + /// If [isTcpTunneling], starts a session only for tcp tunneling. + /// If [isRDP], starts a session only for rdp. + void _connect(String id, + {bool isFileTransfer = false, + bool isTcpTunneling = false, + bool isRDP = false}) async { if (id == '') return; id = id.replaceAll(' ', ''); + assert(!(isFileTransfer && isTcpTunneling && isRDP), + "more than one connect type"); if (isFileTransfer) { await rustDeskWinManager.new_file_transfer(id); + } else if (isTcpTunneling || isRDP) { + await rustDeskWinManager.new_port_forward(id, isRDP); } else { await rustDeskWinManager.new_remote_desktop(id); } @@ -307,12 +317,18 @@ class _PeerCardState extends State<_PeerCard> items: await super.widget.popupMenuItemsFunc(), elevation: 8, ); - if (value == 'remove') { + if (value == 'connect') { + _connect(id); + } else if (value == 'file') { + _connect(id, isFileTransfer: true); + } else if (value == 'tcp-tunnel') { + _connect(id, isTcpTunneling: true); + } else if (value == 'RDP') { + _connect(id, isRDP: true); + } else if (value == 'remove') { await bind.mainRemovePeer(id: id); removePreference(id); Get.forceAppUpdate(); // TODO use inner model / state - } else if (value == 'file') { - _connect(id, isFileTransfer: true); } else if (value == 'add-fav') { final favs = (await bind.mainGetFav()).toList(); if (favs.indexOf(id) < 0) { @@ -325,8 +341,6 @@ class _PeerCardState extends State<_PeerCard> bind.mainStoreFav(favs: favs); Get.forceAppUpdate(); // TODO use inner model / state } - } else if (value == 'connect') { - _connect(id, isFileTransfer: false); } else if (value == 'ab-delete') { gFFI.abModel.deletePeer(id); await gFFI.abModel.updateAb(); @@ -448,6 +462,7 @@ class _PeerCardState extends State<_PeerCard> void _rename(String id) async { var isInProgress = false; var name = await bind.mainGetPeerOption(id: id, key: 'alias'); + var controller = TextEditingController(text: name); if (widget.type == PeerType.ab) { final peer = gFFI.abModel.peers.firstWhere((p) => id == p['id']); if (peer == null) { @@ -456,7 +471,6 @@ class _PeerCardState extends State<_PeerCard> name = peer['alias'] ?? ""; } } - final k = GlobalKey(); gFFI.dialogManager.show((setState, close) { return CustomAlertDialog( title: Text(translate("Rename")), @@ -466,22 +480,9 @@ class _PeerCardState extends State<_PeerCard> Container( padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), child: Form( - key: k, child: TextFormField( - controller: TextEditingController(text: name), + controller: controller, decoration: InputDecoration(border: OutlineInputBorder()), - onChanged: (newStr) { - name = newStr; - }, - validator: (s) { - if (s == null || s.isEmpty) { - return translate("Empty"); - } - return null; - }, - onSaved: (s) { - name = s ?? "unnamed"; - }, ), ), ), @@ -499,22 +500,17 @@ class _PeerCardState extends State<_PeerCard> setState(() { isInProgress = true; }); - if (k.currentState != null) { - if (k.currentState!.validate()) { - k.currentState!.save(); - await bind.mainSetPeerOption( - id: id, key: 'alias', value: name); - if (widget.type == PeerType.ab) { - gFFI.abModel.setPeerOption(id, 'alias', name); - await gFFI.abModel.updateAb(); - } else { - Future.delayed(Duration.zero, () { - this.setState(() {}); - }); - } - close(); - } + name = controller.text; + await bind.mainSetPeerOption(id: id, key: 'alias', value: name); + if (widget.type == PeerType.ab) { + gFFI.abModel.setPeerOption(id, 'alias', name); + await gFFI.abModel.updateAb(); + } else { + Future.delayed(Duration.zero, () { + this.setState(() {}); + }); } + close(); setState(() { isInProgress = false; }); @@ -554,7 +550,7 @@ class RecentPeerCard extends BasePeerCard { : super(peer: peer, key: key, type: PeerType.recent); Future>> _getPopupMenuItems() async { - return [ + var items = [ PopupMenuItem( child: Text(translate('Connect')), value: 'connect'), PopupMenuItem( @@ -570,6 +566,10 @@ class RecentPeerCard extends BasePeerCard { PopupMenuItem( child: Text(translate('Add to Favorites')), value: 'add-fav'), ]; + if (peer.platform == 'Windows') { + items.insert(3, _rdpMenuItem(peer.id)); + } + return items; } } @@ -578,7 +578,7 @@ class FavoritePeerCard extends BasePeerCard { : super(peer: peer, key: key, type: PeerType.fav); Future>> _getPopupMenuItems() async { - return [ + var items = [ PopupMenuItem( child: Text(translate('Connect')), value: 'connect'), PopupMenuItem( @@ -594,6 +594,10 @@ class FavoritePeerCard extends BasePeerCard { PopupMenuItem( child: Text(translate('Remove from Favorites')), value: 'remove-fav'), ]; + if (peer.platform == 'Windows') { + items.insert(3, _rdpMenuItem(peer.id)); + } + return items; } } @@ -602,7 +606,7 @@ class DiscoveredPeerCard extends BasePeerCard { : super(peer: peer, key: key, type: PeerType.discovered); Future>> _getPopupMenuItems() async { - return [ + var items = [ PopupMenuItem( child: Text(translate('Connect')), value: 'connect'), PopupMenuItem( @@ -618,6 +622,10 @@ class DiscoveredPeerCard extends BasePeerCard { PopupMenuItem( child: Text(translate('Add to Favorites')), value: 'add-fav'), ]; + if (peer.platform == 'Windows') { + items.insert(3, _rdpMenuItem(peer.id)); + } + return items; } } @@ -626,7 +634,7 @@ class AddressBookPeerCard extends BasePeerCard { : super(peer: peer, key: key, type: PeerType.ab); Future>> _getPopupMenuItems() async { - return [ + var items = [ PopupMenuItem( child: Text(translate('Connect')), value: 'connect'), PopupMenuItem( @@ -645,6 +653,10 @@ class AddressBookPeerCard extends BasePeerCard { PopupMenuItem( child: Text(translate('Edit Tag')), value: 'ab-edit-tag'), ]; + if (peer.platform == 'Windows') { + items.insert(3, _rdpMenuItem(peer.id)); + } + return items; } } @@ -664,3 +676,136 @@ Future> _forceAlwaysRelayMenuItem(String id) async { ), value: 'force-always-relay'); } + +PopupMenuItem _rdpMenuItem(String id) { + return PopupMenuItem( + child: Row( + children: [ + Text('RDP'), + SizedBox(width: 20), + IconButton( + icon: Icon(Icons.edit), + onPressed: () => _rdpDialog(id), + ) + ], + ), + value: 'RDP'); +} + +void _rdpDialog(String id) async { + final portController = TextEditingController( + text: await bind.mainGetPeerOption(id: id, key: 'rdp_port')); + final userController = TextEditingController( + text: await bind.mainGetPeerOption(id: id, key: 'rdp_username')); + final passwordContorller = TextEditingController( + text: await bind.mainGetPeerOption(id: id, key: 'rdp_password')); + RxBool secure = true.obs; + + gFFI.dialogManager.show((setState, close) { + return CustomAlertDialog( + title: Text('RDP ' + translate('Settings')), + content: ConstrainedBox( + constraints: BoxConstraints(minWidth: 500), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 8.0, + ), + Row( + children: [ + ConstrainedBox( + constraints: BoxConstraints(minWidth: 100), + child: Text( + "${translate('Port')}:", + textAlign: TextAlign.start, + ).marginOnly(bottom: 16.0)), + SizedBox( + width: 24.0, + ), + Expanded( + child: TextField( + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp( + r'^([0-9]|[1-9]\d|[1-9]\d{2}|[1-9]\d{3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])$')) + ], + decoration: InputDecoration( + border: OutlineInputBorder(), hintText: '3389'), + controller: portController, + ), + ), + ], + ), + SizedBox( + height: 8.0, + ), + Row( + children: [ + ConstrainedBox( + constraints: BoxConstraints(minWidth: 100), + child: Text( + "${translate('Username')}:", + textAlign: TextAlign.start, + ).marginOnly(bottom: 16.0)), + SizedBox( + width: 24.0, + ), + Expanded( + child: TextField( + decoration: InputDecoration(border: OutlineInputBorder()), + controller: userController, + ), + ), + ], + ), + SizedBox( + height: 8.0, + ), + Row( + children: [ + ConstrainedBox( + constraints: BoxConstraints(minWidth: 100), + child: Text("${translate('Password')}:") + .marginOnly(bottom: 16.0)), + SizedBox( + width: 24.0, + ), + Expanded( + child: Obx(() => TextField( + obscureText: secure.value, + decoration: InputDecoration( + border: OutlineInputBorder(), + suffixIcon: IconButton( + onPressed: () => secure.value = !secure.value, + icon: Icon(secure.value + ? Icons.visibility_off + : Icons.visibility))), + controller: passwordContorller, + )), + ), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () { + close(); + }, + child: Text(translate("Cancel"))), + TextButton( + onPressed: () async { + await bind.mainSetPeerOption( + id: id, key: 'rdp_port', value: portController.text.trim()); + await bind.mainSetPeerOption( + id: id, key: 'rdp_username', value: userController.text); + await bind.mainSetPeerOption( + id: id, key: 'rdp_password', value: passwordContorller.text); + close(); + }, + child: Text(translate("OK"))), + ], + ); + }); +} diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 9682f19d1..401b7febc 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart'; import 'package:flutter_hbb/desktop/pages/server_page.dart'; import 'package:flutter_hbb/desktop/screen/desktop_file_transfer_screen.dart'; +import 'package:flutter_hbb/desktop/screen/desktop_port_forward_screen.dart'; import 'package:flutter_hbb/desktop/screen/desktop_remote_screen.dart'; import 'package:flutter_hbb/utils/multi_window_manager.dart'; import 'package:get/get.dart'; @@ -47,6 +48,9 @@ Future main(List args) async { case WindowType.FileTransfer: runFileTransferScreen(argument); break; + case WindowType.PortForward: + runPortForwardScreen(argument); + break; default: break; } @@ -133,6 +137,23 @@ void runFileTransferScreen(Map argument) async { ); } +void runPortForwardScreen(Map argument) async { + await initEnv(kAppTypeDesktopPortForward); + runApp( + GetMaterialApp( + navigatorKey: globalKey, + debugShowCheckedModeBanner: false, + title: 'RustDesk - Port Forward', + theme: getCurrentTheme(), + home: DesktopPortForwardScreen(params: argument), + navigatorObservers: [ + // FirebaseAnalyticsObserver(analytics: analytics), + ], + builder: _keepScaleBuilder(), + ), + ); +} + void runConnectionManagerScreen() async { // initialize window WindowOptions windowOptions = getHiddenTitleBarWindowOptions(Size(300, 400)); @@ -190,8 +211,13 @@ class App extends StatelessWidget { // FirebaseAnalyticsObserver(analytics: analytics), ], builder: isAndroid - ? (_, child) => AccessibilityListener( - child: child, + ? (context, child) => AccessibilityListener( + child: MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaleFactor: 1.0, + ), + child: child ?? Container(), + ), ) : _keepScaleBuilder(), ), diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index b0ac2dc7e..58ea849ce 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -1051,17 +1051,24 @@ class FFI { return []; } - /// Connect with the given [id]. Only transfer file if [isFileTransfer]. + /// Connect with the given [id]. Only transfer file if [isFileTransfer], only port forward if [isPortForward]. void connect(String id, - {bool isFileTransfer = false, double tabBarHeight = 0.0}) { - if (!isFileTransfer) { + {bool isFileTransfer = false, + bool isPortForward = false, + double tabBarHeight = 0.0}) { + assert(!(isFileTransfer && isPortForward), "more than one connect type"); + if (isFileTransfer) { + id = 'ft_${id}'; + } else if (isPortForward) { + id = 'pf_${id}'; + } else { chatModel.resetClientMode(); canvasModel.id = id; imageModel._id = id; cursorModel.id = id; } - id = isFileTransfer ? 'ft_${id}' : id; - final stream = bind.sessionConnect(id: id, isFileTransfer: isFileTransfer); + final stream = bind.sessionConnect( + id: id, isFileTransfer: isFileTransfer, isPortForward: isPortForward); final cb = ffiModel.startEventListener(id); () async { await for (final message in stream) { diff --git a/flutter/lib/utils/multi_window_manager.dart b/flutter/lib/utils/multi_window_manager.dart index 9b26870c0..b01b84a9d 100644 --- a/flutter/lib/utils/multi_window_manager.dart +++ b/flutter/lib/utils/multi_window_manager.dart @@ -35,6 +35,7 @@ class RustDeskMultiWindowManager { int? _remoteDesktopWindowId; int? _fileTransferWindowId; + int? _portForwardWindowId; Future new_remote_desktop(String remote_id) async { final msg = @@ -87,6 +88,34 @@ class RustDeskMultiWindowManager { } } + Future new_port_forward(String remote_id, bool isRDP) async { + final msg = jsonEncode({ + "type": WindowType.PortForward.index, + "id": remote_id, + "isRDP": isRDP + }); + + try { + final ids = await DesktopMultiWindow.getAllSubWindowIds(); + if (!ids.contains(_portForwardWindowId)) { + _portForwardWindowId = null; + } + } on Error { + _portForwardWindowId = null; + } + if (_portForwardWindowId == null) { + final portForwardController = await DesktopMultiWindow.createWindow(msg); + portForwardController + ..setFrame(const Offset(0, 0) & const Size(1280, 720)) + ..center() + ..setTitle("rustdesk - port forward") + ..show(); + _portForwardWindowId = portForwardController.windowId; + } else { + return call(WindowType.PortForward, "new_port_forward", msg); + } + } + Future call(WindowType type, String methodName, dynamic args) async { int? windowId = findWindowByType(type); if (windowId == null) { @@ -104,7 +133,7 @@ class RustDeskMultiWindowManager { case WindowType.FileTransfer: return _fileTransferWindowId; case WindowType.PortForward: - break; + return _portForwardWindowId; case WindowType.Unknown: break; } @@ -120,7 +149,7 @@ class RustDeskMultiWindowManager { await Future.wait(WindowType.values.map((e) => closeWindows(e))); } - Future closeWindows(WindowType type) async { + Future closeWindows(WindowType type) async { if (type == WindowType.Main) { // skip main window, use window manager instead return; diff --git a/src/flutter.rs b/src/flutter.rs index ca00807f1..1a0499565 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -70,7 +70,13 @@ impl Session { /// /// * `id` - The identifier of the remote session with prefix. Regex: [\w]*[\_]*[\d]+ /// * `is_file_transfer` - If the session is used for file transfer. - pub fn start(identifier: &str, is_file_transfer: bool, events2ui: StreamSink) { + /// * `is_port_forward` - If the session is used for port forward. + pub fn start( + identifier: &str, + is_file_transfer: bool, + is_port_forward: bool, + events2ui: StreamSink, + ) { // TODO check same id let session_id = get_session_id(identifier.to_owned()); LocalConfig::set_remote_id(&session_id); @@ -83,17 +89,17 @@ impl Session { lc: Default::default(), events2ui, }; - session - .lc - .write() - .unwrap() - .initialize(session_id.clone(), is_file_transfer, false); + session.lc.write().unwrap().initialize( + session_id.clone(), + is_file_transfer, + is_port_forward, + ); SESSIONS .write() .unwrap() .insert(identifier.to_owned(), session.clone()); std::thread::spawn(move || { - Connection::start(session, is_file_transfer); + Connection::start(session, is_file_transfer, is_port_forward); }); } @@ -201,7 +207,7 @@ impl Session { self.send(Data::Close); let session = self.clone(); std::thread::spawn(move || { - Connection::start(session, false); + Connection::start(session, false, false); }); } @@ -719,18 +725,21 @@ impl Connection { /// /// * `session` - The session to create a new connection for. /// * `is_file_transfer` - Whether the connection is for file transfer. + /// * `is_port_forward` - Whether the connection is for port forward. #[tokio::main(flavor = "current_thread")] - async fn start(session: Session, is_file_transfer: bool) { + async fn start(session: Session, is_file_transfer: bool, is_port_forward: bool) { let mut last_recv_time = Instant::now(); let (sender, mut receiver) = mpsc::unbounded_channel::(); let mut stop_clipboard = None; - if !is_file_transfer { + if !is_file_transfer && !is_port_forward { stop_clipboard = Self::start_clipboard(sender.clone(), session.lc.clone()); } *session.sender.write().unwrap() = Some(sender); let conn_type = if is_file_transfer { session.lc.write().unwrap().is_file_transfer = true; ConnType::FILE_TRANSFER + } else if is_port_forward { + ConnType::PORT_FORWARD // TODO: RDP } else { ConnType::DEFAULT_CONN }; diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index aa46e4faf..d9bc31d96 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -111,8 +111,9 @@ pub fn session_connect( events2ui: StreamSink, id: String, is_file_transfer: bool, + is_port_forward: bool, ) -> ResultType<()> { - Session::start(&id, is_file_transfer, events2ui); + Session::start(&id, is_file_transfer, is_port_forward, events2ui); Ok(()) } @@ -592,12 +593,41 @@ pub fn main_load_lan_peers() { { let data = HashMap::from([ ("name", "load_lan_peers".to_owned()), - ("peers", serde_json::to_string(&get_lan_peers()).unwrap_or_default()), + ( + "peers", + serde_json::to_string(&get_lan_peers()).unwrap_or_default(), + ), ]); s.add(serde_json::ser::to_string(&data).unwrap_or("".to_owned())); }; } +pub fn main_add_port_forward(id: String, local_port: i32, remote_host: String, remote_port: i32) { + let mut config = get_peer(id.clone()); + if config + .port_forwards + .iter() + .filter(|x| x.0 == local_port) + .next() + .is_some() + { + return; + } + let pf = (local_port, remote_host, remote_port); + config.port_forwards.push(pf); + config.store(&id); +} + +pub fn main_remove_port_forward(id: String, local_port: i32) { + let mut config = get_peer(id.clone()); + config.port_forwards = config + .port_forwards + .drain(..) + .filter(|x| x.0 != local_port) + .collect(); + config.store(&id); +} + pub fn main_get_last_remote_id() -> String { // if !config::APP_DIR.read().unwrap().is_empty() { // res = LocalConfig::get_remote_id();