diff --git a/.gitignore b/.gitignore index 5b26711c5..9d152ac1d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/build /target .vscode .idea diff --git a/build.rs b/build.rs index 7d6aac441..860ebae77 100644 --- a/build.rs +++ b/build.rs @@ -77,6 +77,10 @@ fn install_oboe() { } fn gen_flutter_rust_bridge() { + let llvm_path = match std::env::var("LLVM_HOME") { + Ok(path) => Some(vec![path]), + Err(_) => None, + }; // Tell Cargo that if the given file changes, to rerun this build script. println!("cargo:rerun-if-changed=src/flutter_ffi.rs"); // settings for fbr_codegen @@ -88,6 +92,7 @@ fn gen_flutter_rust_bridge() { // Path of output generated C header c_output: Some(vec!["flutter/macos/Runner/bridge_generated.h".to_string()]), // for other options lets use default + llvm_path, ..Default::default() }; // run fbr_codegen diff --git a/flutter/.gitignore b/flutter/.gitignore index ede37092d..e5db34d22 100644 --- a/flutter/.gitignore +++ b/flutter/.gitignore @@ -48,13 +48,11 @@ lib/generated_bridge.dart lib/generated_bridge.freezed.dart # Flutter Generated Files -linux/flutter/generated_plugin_registrant.cc -linux/flutter/generated_plugin_registrant.h -linux/flutter/generated_plugins.cmake -macos/Flutter/GeneratedPluginRegistrant.swift -windows/flutter/generated_plugin_registrant.cc -windows/flutter/generated_plugin_registrant.h -windows/flutter/generated_plugins.cmake +**/flutter/GeneratedPluginRegistrant.swift +**/flutter/generated_plugin_registrant.cc +**/flutter/generated_plugin_registrant.h +**/flutter/generated_plugins.cmake +**/Runner/bridge_generated.h flutter_export_environment.sh Flutter-Generated.xcconfig key.jks diff --git a/flutter/lib/desktop/pages/connection_page.dart b/flutter/lib/desktop/pages/connection_page.dart index 14a1cc4e7..42c41f8b9 100644 --- a/flutter/lib/desktop/pages/connection_page.dart +++ b/flutter/lib/desktop/pages/connection_page.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:contextmenu/contextmenu.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart'; +import 'package:flutter_hbb/desktop/widgets/peer_widget.dart'; import 'package:flutter_hbb/utils/multi_window_manager.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; @@ -15,6 +16,7 @@ import '../../mobile/pages/home_page.dart'; import '../../mobile/pages/scan_page.dart'; import '../../mobile/pages/settings_page.dart'; import '../../models/model.dart'; +import '../../models/peer_model.dart'; enum RemoteType { recently, favorite, discovered, addressBook } @@ -58,9 +60,7 @@ class _ConnectionPageState extends State { Widget build(BuildContext context) { if (_idController.text.isEmpty) _idController.text = gFFI.getId(); return Container( - decoration: BoxDecoration( - color: MyTheme.grayBg - ), + decoration: BoxDecoration(color: MyTheme.grayBg), child: Column( mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.max, @@ -73,7 +73,9 @@ class _ConnectionPageState extends State { ], ).marginOnly(top: 16.0, left: 16.0), SizedBox(height: 12), - Divider(thickness: 1,), + Divider( + thickness: 1, + ), Expanded( child: DefaultTabController( length: 4, @@ -85,47 +87,61 @@ class _ConnectionPageState extends State { isScrollable: true, indicatorSize: TabBarIndicatorSize.label, tabs: [ - Tab(child: Text(translate("Recent Sessions")),), - Tab(child: Text(translate("Favorites")),), - Tab(child: Text(translate("Discovered")),), - Tab(child: Text(translate("Address Book")),), + Tab( + child: Text(translate("Recent Sessions")), + ), + Tab( + child: Text(translate("Favorites")), + ), + Tab( + child: Text(translate("Discovered")), + ), + Tab( + child: Text(translate("Address Book")), + ), ]), - Expanded(child: TabBarView(children: [ - FutureBuilder(future: getPeers(rType: RemoteType.recently), - builder: (context, snapshot){ - if (snapshot.hasData) { - return snapshot.data!; - } else { - return Offstage(); - } - }), - FutureBuilder( - future: getPeers(rType: RemoteType.favorite), - builder: (context, snapshot) { - if (snapshot.hasData) { - return snapshot.data!; - } else { - return Offstage(); - } - }), - FutureBuilder( - future: getPeers(rType: RemoteType.discovered), - builder: (context, snapshot) { - if (snapshot.hasData) { - return snapshot.data!; - } else { - return Offstage(); - } - }), - FutureBuilder( - future: buildAddressBook(context), - builder: (context, snapshot) { - if (snapshot.hasData) { - return snapshot.data!; - } else { - return Offstage(); - } - }), + Expanded( + child: TabBarView(children: [ + RecentPeerWidget(), + FavoritePeerWidget(), + DiscoveredPeerWidget(), + AddressBookPeerWidget(), + // FutureBuilder( + // future: getPeers(rType: RemoteType.recently), + // builder: (context, snapshot) { + // if (snapshot.hasData) { + // return snapshot.data!; + // } else { + // return Offstage(); + // } + // }), + // FutureBuilder( + // future: getPeers(rType: RemoteType.favorite), + // builder: (context, snapshot) { + // if (snapshot.hasData) { + // return snapshot.data!; + // } else { + // return Offstage(); + // } + // }), + // FutureBuilder( + // future: getPeers(rType: RemoteType.discovered), + // builder: (context, snapshot) { + // if (snapshot.hasData) { + // return snapshot.data!; + // } else { + // return Offstage(); + // } + // }), + // FutureBuilder( + // future: buildAddressBook(context), + // builder: (context, snapshot) { + // if (snapshot.hasData) { + // return snapshot.data!; + // } else { + // return Offstage(); + // } + // }), ]).paddingSymmetric(horizontal: 12.0, vertical: 4.0)) ], )), @@ -166,20 +182,20 @@ class _ConnectionPageState extends State { return _updateUrl.isEmpty ? SizedBox(height: 0) : InkWell( - onTap: () async { - final url = _updateUrl + '.apk'; - if (await canLaunch(url)) { - await launch(url); - } - }, - child: Container( - alignment: AlignmentDirectional.center, - width: double.infinity, - color: Colors.pinkAccent, - padding: EdgeInsets.symmetric(vertical: 12), - child: Text(translate('Download new version'), - style: TextStyle( - color: Colors.white, fontWeight: FontWeight.bold)))); + onTap: () async { + final url = _updateUrl + '.apk'; + if (await canLaunch(url)) { + await launch(url); + } + }, + child: Container( + alignment: AlignmentDirectional.center, + width: double.infinity, + color: Colors.pinkAccent, + padding: EdgeInsets.symmetric(vertical: 12), + child: Text(translate('Download new version'), + style: TextStyle( + color: Colors.white, fontWeight: FontWeight.bold)))); } /// UI for the search bar. @@ -214,8 +230,8 @@ class _ConnectionPageState extends State { labelText: translate('Control Remote Desktop'), // hintText: 'Enter your remote ID', // border: InputBorder., - border: OutlineInputBorder( - borderRadius: BorderRadius.zero), + border: + OutlineInputBorder(borderRadius: BorderRadius.zero), helperStyle: TextStyle( fontWeight: FontWeight.bold, fontSize: 16, @@ -238,8 +254,7 @@ class _ConnectionPageState extends State { ], ), Padding( - padding: const EdgeInsets.only( - top: 16.0), + padding: const EdgeInsets.only(top: 16.0), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -996,13 +1011,13 @@ class _WebMenuState extends State { icon: Icon(Icons.more_vert), itemBuilder: (context) { return (isIOS - ? [ - PopupMenuItem( - child: Icon(Icons.qr_code_scanner, color: Colors.black), - value: "scan", - ) - ] - : >[]) + + ? [ + PopupMenuItem( + child: Icon(Icons.qr_code_scanner, color: Colors.black), + value: "scan", + ) + ] + : >[]) + [ PopupMenuItem( child: Text(translate('ID/Relay Server')), @@ -1012,13 +1027,13 @@ class _WebMenuState extends State { (getUrl().contains('admin.rustdesk.com') ? >[] : [ - PopupMenuItem( - child: Text(username == null - ? translate("Login") - : translate("Logout") + ' ($username)'), - value: "login", - ) - ]) + + PopupMenuItem( + child: Text(username == null + ? translate("Login") + : translate("Logout") + ' ($username)'), + value: "login", + ) + ]) + [ PopupMenuItem( child: Text(translate('About') + ' RustDesk'), diff --git a/flutter/lib/desktop/widgets/peer_widget.dart b/flutter/lib/desktop/widgets/peer_widget.dart new file mode 100644 index 000000000..e0c82bb30 --- /dev/null +++ b/flutter/lib/desktop/widgets/peer_widget.dart @@ -0,0 +1,244 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:provider/provider.dart'; +import 'package:visibility_detector/visibility_detector.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../models/peer_model.dart'; +import '../../common.dart'; +import 'peercard_widget.dart'; + +typedef OffstageFunc = bool Function(Peer peer); +typedef PeerCardWidgetFunc = Widget Function(Peer peer); + +class _PeerWidget extends StatefulWidget { + late final _name; + late final _peers; + late final OffstageFunc _offstageFunc; + late final PeerCardWidgetFunc _peerCardWidgetFunc; + _PeerWidget(String name, List peers, OffstageFunc offstageFunc, + PeerCardWidgetFunc peerCardWidgetFunc, + {Key? key}) + : super(key: key) { + _name = name; + _peers = peers; + _offstageFunc = offstageFunc; + _peerCardWidgetFunc = peerCardWidgetFunc; + } + + @override + _PeerWidgetState createState() => _PeerWidgetState(); +} + +/// State for the peer widget. +class _PeerWidgetState extends State<_PeerWidget> with WindowListener { + static const int _maxQueryCount = 3; + + var _curPeers = Set(); + var _lastChangeTime = DateTime.now(); + var _lastQueryPeers = Set(); + var _lastQueryTime = DateTime.now().subtract(Duration(hours: 1)); + var _queryCoun = 0; + var _exit = false; + + _PeerWidgetState() { + _startCheckOnlines(); + } + + @override + void initState() { + windowManager.addListener(this); + super.initState(); + } + + @override + void dispose() { + windowManager.removeListener(this); + _exit = true; + super.dispose(); + } + + @override + void onWindowFocus() { + _queryCoun = 0; + } + + @override + Widget build(BuildContext context) { + final space = 8.0; + return ChangeNotifierProvider( + create: (context) => Peers(super.widget._name, super.widget._peers), + child: SingleChildScrollView( + child: Consumer( + builder: (context, peers, child) => Wrap( + children: () { + final cards = []; + peers.peers.forEach((peer) { + cards.add(Offstage( + offstage: super.widget._offstageFunc(peer), + child: Container( + width: 225, + height: 150, + child: VisibilityDetector( + key: Key('${peer.id}'), + onVisibilityChanged: (info) { + final peerId = (info.key as ValueKey).value; + if (info.visibleFraction > 0.00001) { + _curPeers.add(peerId); + } else { + _curPeers.remove(peerId); + } + _lastChangeTime = DateTime.now(); + }, + child: super.widget._peerCardWidgetFunc(peer), + ), + ))); + }); + return cards; + }(), + spacing: space, + runSpacing: space))), + ); + } + + // ignore: todo + // TODO: variables walk through async tasks? + void _startCheckOnlines() { + () async { + while (!_exit) { + final now = DateTime.now(); + if (!setEquals(_curPeers, _lastQueryPeers)) { + if (now.difference(_lastChangeTime) > Duration(seconds: 1)) { + gFFI.ffiModel.platformFFI.ffiBind + .queryOnlines(ids: _curPeers.toList(growable: false)); + _lastQueryPeers = {..._curPeers}; + _lastQueryTime = DateTime.now(); + _queryCoun = 0; + } + } else { + if (_queryCoun < _maxQueryCount) { + if (now.difference(_lastQueryTime) > Duration(seconds: 20)) { + gFFI.ffiModel.platformFFI.ffiBind + .queryOnlines(ids: _curPeers.toList(growable: false)); + _lastQueryTime = DateTime.now(); + _queryCoun += 1; + } + } + } + await Future.delayed(Duration(milliseconds: 300)); + } + }(); + } +} + +abstract class BasePeerWidget extends StatelessWidget { + late final _name; + late final OffstageFunc _offstageFunc; + late final PeerCardWidgetFunc _peerCardWidgetFunc; + + BasePeerWidget({Key? key}) : super(key: key) {} + + @override + Widget build(BuildContext context) { + return FutureBuilder(future: () async { + return _PeerWidget( + _name, await _loadPeers(), _offstageFunc, _peerCardWidgetFunc); + }(), builder: (context, snapshot) { + if (snapshot.hasData) { + return snapshot.data!; + } else { + return Offstage(); + } + }); + } + + @protected + Future> _loadPeers(); +} + +class RecentPeerWidget extends BasePeerWidget { + RecentPeerWidget({Key? key}) : super(key: key) { + super._name = "recent peer"; + super._offstageFunc = (Peer _peer) => false; + super._peerCardWidgetFunc = (Peer peer) => RecentPeerCard(peer: peer); + } + + Future> _loadPeers() async { + return gFFI.peers(); + } +} + +class FavoritePeerWidget extends BasePeerWidget { + FavoritePeerWidget({Key? key}) : super(key: key) { + super._name = "favorite peer"; + super._offstageFunc = (Peer _peer) => false; + super._peerCardWidgetFunc = (Peer peer) => FavoritePeerCard(peer: peer); + } + + @override + Future> _loadPeers() async { + return await gFFI.bind.mainGetFav().then((peers) async { + final peersEntities = await Future.wait(peers + .map((id) => gFFI.bind.mainGetPeers(id: id)) + .toList(growable: false)) + .then((peers_str) { + final len = peers_str.length; + final ps = List.empty(growable: true); + for (var i = 0; i < len; i++) { + print("${peers[i]}: ${peers_str[i]}"); + ps.add(Peer.fromJson(peers[i], jsonDecode(peers_str[i])['info'])); + } + return ps; + }); + return peersEntities; + }); + } +} + +class DiscoveredPeerWidget extends BasePeerWidget { + DiscoveredPeerWidget({Key? key}) : super(key: key) { + super._name = "discovered peer"; + super._offstageFunc = (Peer _peer) => false; + super._peerCardWidgetFunc = (Peer peer) => DiscoveredPeerCard(peer: peer); + } + + Future> _loadPeers() async { + return await gFFI.bind.mainGetLanPeers().then((peers_string) { + debugPrint(peers_string); + return []; + }); + } +} + +class AddressBookPeerWidget extends BasePeerWidget { + AddressBookPeerWidget({Key? key}) : super(key: key) { + super._name = "address book peer"; + super._offstageFunc = + (Peer peer) => !_hitTag(gFFI.abModel.selectedTags, peer.tags); + super._peerCardWidgetFunc = (Peer peer) => AddressBookPeerCard(peer: peer); + } + + Future> _loadPeers() async { + return gFFI.abModel.peers.map((e) { + return Peer.fromJson(e['id'], e); + }).toList(); + } + + bool _hitTag(List selectedTags, List idents) { + if (selectedTags.isEmpty) { + return true; + } + if (idents.isEmpty) { + return false; + } + for (final tag in selectedTags) { + if (!idents.contains(tag)) { + return false; + } + } + return true; + } +} diff --git a/flutter/lib/desktop/widgets/peercard_widget.dart b/flutter/lib/desktop/widgets/peercard_widget.dart new file mode 100644 index 000000000..b8c6d54de --- /dev/null +++ b/flutter/lib/desktop/widgets/peercard_widget.dart @@ -0,0 +1,371 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/utils/multi_window_manager.dart'; +import 'package:get/get.dart'; +import 'package:contextmenu/contextmenu.dart'; + +import '../../common.dart'; +import '../../models/model.dart'; +import '../../models/peer_model.dart'; + +class _PeerCard extends StatefulWidget { + final Peer peer; + final List> popupMenuItems; + + _PeerCard({required this.peer, required this.popupMenuItems, Key? key}) + : super(key: key); + + @override + _PeerCardState createState() => _PeerCardState(); +} + +/// State for the connection page. +class _PeerCardState extends State<_PeerCard> { + var _menuPos; + + @override + Widget build(BuildContext context) { + final peer = super.widget.peer; + var deco = Rx(BoxDecoration( + border: Border.all(color: Colors.transparent, width: 1.0), + borderRadius: BorderRadius.circular(20))); + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: MouseRegion( + onEnter: (evt) { + deco.value = BoxDecoration( + border: Border.all(color: Colors.blue, width: 1.0), + borderRadius: BorderRadius.circular(20)); + }, + onExit: (evt) { + deco.value = BoxDecoration( + border: Border.all(color: Colors.transparent, width: 1.0), + borderRadius: BorderRadius.circular(20)); + }, + child: _buildPeerTile(context, peer, deco), + )); + } + + Widget _buildPeerTile( + BuildContext context, Peer peer, Rx deco) { + return Obx( + () => Container( + decoration: deco.value, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: str2color('${peer.id}${peer.platform}', 0x7f), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(6), + child: _getPlatformImage('${peer.platform}'), + ), + Row( + children: [ + Expanded( + child: Tooltip( + message: '${peer.username}@${peer.hostname}', + child: Text( + '${peer.username}@${peer.hostname}', + style: TextStyle( + color: Colors.white70, fontSize: 12), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + ], + ).paddingAll(4.0), + ), + ], + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row(children: [ + Padding( + padding: EdgeInsets.fromLTRB(0, 4, 8, 4), + child: CircleAvatar( + radius: 5, + backgroundColor: + peer.online ? Colors.green : Colors.yellow)), + Text('${peer.id}') + ]), + InkWell( + child: Icon(Icons.more_vert), + onTapDown: (e) { + final x = e.globalPosition.dx; + final y = e.globalPosition.dy; + _menuPos = RelativeRect.fromLTRB(x, y, x, y); + }, + onTap: () { + _showPeerMenu(context, peer.id); + }), + ], + ).paddingSymmetric(vertical: 8.0, horizontal: 12.0) + ], + ), + ), + ); + } + + /// Connect to a peer with [id]. + /// If [isFileTransfer], starts a session only for file transfer. + void _connect(String id, {bool isFileTransfer = false}) async { + if (id == '') return; + id = id.replaceAll(' ', ''); + if (isFileTransfer) { + await rustDeskWinManager.new_file_transfer(id); + } else { + await rustDeskWinManager.new_remote_desktop(id); + } + FocusScopeNode currentFocus = FocusScope.of(context); + if (!currentFocus.hasPrimaryFocus) { + currentFocus.unfocus(); + } + } + + /// Show the peer menu and handle user's choice. + /// User might remove the peer or send a file to the peer. + void _showPeerMenu(BuildContext context, String id) async { + var value = await showMenu( + context: context, + position: this._menuPos, + items: super.widget.popupMenuItems, + elevation: 8, + ); + if (value == 'remove') { + setState(() => gFFI.setByName('remove', '$id')); + () async { + removePreference(id); + }(); + } else if (value == 'file') { + _connect(id, isFileTransfer: true); + } else if (value == 'add-fav') { + } else if (value == 'connect') { + _connect(id, isFileTransfer: false); + } else if (value == 'ab-delete') { + gFFI.abModel.deletePeer(id); + await gFFI.abModel.updateAb(); + setState(() {}); + } else if (value == 'ab-edit-tag') { + _abEditTag(id); + } + } + + Widget _buildTag(String tagName, RxList rxTags, + {Function()? onTap}) { + return ContextMenuArea( + width: 100, + builder: (context) => [ + ListTile( + title: Text(translate("Delete")), + onTap: () { + gFFI.abModel.deleteTag(tagName); + gFFI.abModel.updateAb(); + Future.delayed(Duration.zero, () => Get.back()); + }, + ) + ], + child: GestureDetector( + onTap: onTap, + child: Obx( + () => Container( + decoration: BoxDecoration( + color: rxTags.contains(tagName) ? Colors.blue : null, + border: Border.all(color: MyTheme.darkGray), + borderRadius: BorderRadius.circular(10)), + margin: EdgeInsets.symmetric(horizontal: 4.0, vertical: 8.0), + padding: EdgeInsets.symmetric(vertical: 2.0, horizontal: 8.0), + child: Text( + tagName, + style: TextStyle( + color: rxTags.contains(tagName) ? MyTheme.white : null), + ), + ), + ), + ), + ); + } + + /// Get the image for the current [platform]. + Widget _getPlatformImage(String platform) { + platform = platform.toLowerCase(); + if (platform == 'mac os') + platform = 'mac'; + else if (platform != 'linux' && platform != 'android') platform = 'win'; + return Image.asset('assets/$platform.png', height: 50); + } + + void _abEditTag(String id) { + var isInProgress = false; + + final tags = List.of(gFFI.abModel.tags); + var selectedTag = gFFI.abModel.getPeerTags(id).obs; + + DialogManager.show((setState, close) { + return CustomAlertDialog( + title: Text(translate("Edit Tag")), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Wrap( + children: tags + .map((e) => _buildTag(e, selectedTag, onTap: () { + if (selectedTag.contains(e)) { + selectedTag.remove(e); + } else { + selectedTag.add(e); + } + })) + .toList(growable: false), + ), + ), + Offstage(offstage: !isInProgress, child: LinearProgressIndicator()) + ], + ), + actions: [ + TextButton( + onPressed: () { + close(); + }, + child: Text(translate("Cancel"))), + TextButton( + onPressed: () async { + setState(() { + isInProgress = true; + }); + gFFI.abModel.changeTagForPeer(id, selectedTag); + await gFFI.abModel.updateAb(); + close(); + }, + child: Text(translate("OK"))), + ], + ); + }); + } +} + +abstract class BasePeerCard extends StatelessWidget { + final Peer peer; + BasePeerCard({required this.peer, Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return _PeerCard(peer: peer, popupMenuItems: _getPopupMenuItems()); + } + + @protected + List> _getPopupMenuItems(); +} + +class RecentPeerCard extends BasePeerCard { + RecentPeerCard({required Peer peer, Key? key}) : super(peer: peer, key: key); + + List> _getPopupMenuItems() { + return [ + PopupMenuItem( + child: Text(translate('Connect')), value: 'connect'), + PopupMenuItem( + child: Text(translate('Transfer File')), value: 'file'), + PopupMenuItem( + child: Text(translate('TCP Tunneling')), value: 'tcp-tunnel'), + PopupMenuItem(child: Text(translate('Rename')), value: 'rename'), + PopupMenuItem(child: Text(translate('Remove')), value: 'remove'), + PopupMenuItem( + child: Text(translate('Unremember Password')), + value: 'unremember-password'), + PopupMenuItem( + child: Text(translate('Edit Tag')), value: 'ab-edit-tag'), + ]; + } +} + +class FavoritePeerCard extends BasePeerCard { + FavoritePeerCard({required Peer peer, Key? key}) + : super(peer: peer, key: key); + + List> _getPopupMenuItems() { + return [ + PopupMenuItem( + child: Text(translate('Connect')), value: 'connect'), + PopupMenuItem( + child: Text(translate('Transfer File')), value: 'file'), + PopupMenuItem( + child: Text(translate('TCP Tunneling')), value: 'tcp-tunnel'), + PopupMenuItem(child: Text(translate('Rename')), value: 'rename'), + PopupMenuItem(child: Text(translate('Remove')), value: 'remove'), + PopupMenuItem( + child: Text(translate('Unremember Password')), + value: 'unremember-password'), + PopupMenuItem( + child: Text(translate('Remove from Favorites')), value: 'remove-fav'), + ]; + } +} + +class DiscoveredPeerCard extends BasePeerCard { + DiscoveredPeerCard({required Peer peer, Key? key}) + : super(peer: peer, key: key); + + List> _getPopupMenuItems() { + return [ + PopupMenuItem( + child: Text(translate('Connect')), value: 'connect'), + PopupMenuItem( + child: Text(translate('Transfer File')), value: 'file'), + PopupMenuItem( + child: Text(translate('TCP Tunneling')), value: 'tcp-tunnel'), + PopupMenuItem(child: Text(translate('Rename')), value: 'rename'), + PopupMenuItem(child: Text(translate('Remove')), value: 'remove'), + PopupMenuItem( + child: Text(translate('Unremember Password')), + value: 'unremember-password'), + PopupMenuItem( + child: Text(translate('Edit Tag')), value: 'ab-edit-tag'), + ]; + } +} + +class AddressBookPeerCard extends BasePeerCard { + AddressBookPeerCard({required Peer peer, Key? key}) + : super(peer: peer, key: key); + + List> _getPopupMenuItems() { + return [ + PopupMenuItem( + child: Text(translate('Connect')), value: 'connect'), + PopupMenuItem( + child: Text(translate('Transfer File')), value: 'file'), + PopupMenuItem( + child: Text(translate('TCP Tunneling')), value: 'tcp-tunnel'), + PopupMenuItem(child: Text(translate('Rename')), value: 'rename'), + PopupMenuItem( + child: Text(translate('Remove')), value: 'ab-delete'), + PopupMenuItem( + child: Text(translate('Unremember Password')), + value: 'unremember-password'), + PopupMenuItem( + child: Text(translate('Add to Favorites')), value: 'add-fav'), + ]; + } +} diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index fa8210618..bc64ff6f5 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -21,6 +21,7 @@ import '../common.dart'; import '../mobile/widgets/dialog.dart'; import '../mobile/widgets/overlay.dart'; import 'native_model.dart' if (dart.library.html) 'web_model.dart'; +import 'peer_model.dart'; typedef HandleMsgBox = void Function(Map evt, String id); bool _waitForImage = false; @@ -1092,7 +1093,7 @@ class FFI { Future> getAudioInputs() async { return await bind.mainGetSoundInputs(); } - + String getDefaultAudioInput() { final input = getOption('audio-input'); if (input.isEmpty && Platform.isWindows) { @@ -1110,21 +1111,6 @@ class FFI { } } -class Peer { - final String id; - final String username; - final String hostname; - final String platform; - final List tags; - - Peer.fromJson(String id, Map json) - : id = id, - username = json['username'] ?? '', - hostname = json['hostname'] ?? '', - platform = json['platform'] ?? '', - tags = json['tags'] ?? []; -} - class Display { double x = 0; double y = 0; diff --git a/flutter/lib/models/native_model.dart b/flutter/lib/models/native_model.dart index c0fd4dfa1..511aa5ffe 100644 --- a/flutter/lib/models/native_model.dart +++ b/flutter/lib/models/native_model.dart @@ -6,6 +6,7 @@ import 'dart:typed_data'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:external_path/external_path.dart'; import 'package:ffi/ffi.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path_provider/path_provider.dart'; @@ -21,6 +22,7 @@ class RgbaFrame extends Struct { typedef F2 = Pointer Function(Pointer, Pointer); typedef F3 = void Function(Pointer, Pointer); +typedef HandleEvent = void Function(Map evt); /// FFI wrapper around the native Rust core. /// Hides the platform differences. @@ -30,6 +32,7 @@ class PlatformFFI { String _homeDir = ''; F2? _getByName; F3? _setByName; + var _eventHandlers = Map>(); late RustdeskImpl _ffiBind; void Function(Map)? _eventCallback; @@ -40,6 +43,31 @@ class PlatformFFI { return packageInfo.version; } + bool registerEventHandler( + String event_name, String handler_name, HandleEvent handler) { + debugPrint('registerEventHandler $event_name $handler_name'); + var handlers = _eventHandlers[event_name]; + if (handlers == null) { + _eventHandlers[event_name] = {handler_name: handler}; + return true; + } else { + if (handlers.containsKey(handler_name)) { + return false; + } else { + handlers[handler_name] = handler; + return true; + } + } + } + + void unregisterEventHandler(String event_name, String handler_name) { + debugPrint('unregisterEventHandler $event_name $handler_name'); + var handlers = _eventHandlers[event_name]; + if (handlers != null) { + handlers.remove(handler_name); + } + } + /// Send **get** command to the Rust core based on [name] and [arg]. /// Return the result as a string. String getByName(String name, [String arg = '']) { @@ -138,6 +166,22 @@ class PlatformFFI { version = await getVersion(); } + bool _tryHandle(Map evt) { + final name = evt['name']; + if (name != null) { + final handlers = _eventHandlers[name]; + if (handlers != null) { + if (handlers.isNotEmpty) { + handlers.values.forEach((handler) { + handler(evt); + }); + return true; + } + } + } + return false; + } + /// Start listening to the Rust core's events and frames. void _startListenEvent(RustdeskImpl rustdeskImpl) { () async { @@ -145,7 +189,10 @@ class PlatformFFI { if (_eventCallback != null) { try { Map event = json.decode(message); - _eventCallback!(event); + // _tryHandle here may be more flexible than _eventCallback + if (!_tryHandle(event)) { + _eventCallback!(event); + } } catch (e) { print('json.decode fail(): $e'); } diff --git a/flutter/lib/models/peer_model.dart b/flutter/lib/models/peer_model.dart new file mode 100644 index 000000000..939d16ede --- /dev/null +++ b/flutter/lib/models/peer_model.dart @@ -0,0 +1,89 @@ +import 'package:flutter/foundation.dart'; +import '../../common.dart'; + +class Peer { + final String id; + final String username; + final String hostname; + final String platform; + final List tags; + bool online = false; + + Peer.fromJson(String id, Map json) + : id = id, + username = json['username'] ?? '', + hostname = json['hostname'] ?? '', + platform = json['platform'] ?? '', + tags = json['tags'] ?? []; + + Peer({ + required this.id, + required this.username, + required this.hostname, + required this.platform, + required this.tags, + }); + + Peer.loading() + : this( + id: '...', + username: '...', + hostname: '...', + platform: '...', + tags: []); +} + +class Peers extends ChangeNotifier { + late String _name; + late var _peers; + static const cbQueryOnlines = 'callback_query_onlines'; + + Peers(String name, List peers) { + _name = name; + _peers = peers; + gFFI.ffiModel.platformFFI.registerEventHandler(cbQueryOnlines, _name, + (evt) { + _updateOnlineState(evt); + }); + } + + List get peers => _peers; + + @override + void dispose() { + gFFI.ffiModel.platformFFI.unregisterEventHandler(cbQueryOnlines, _name); + super.dispose(); + } + + Peer getByIndex(int index) { + if (index < _peers.length) { + return _peers[index]; + } else { + return Peer.loading(); + } + } + + int getPeersCount() { + return _peers.length; + } + + void _updateOnlineState(Map evt) { + evt['onlines'].split(',').forEach((online) { + for (var i = 0; i < _peers.length; i++) { + if (_peers[i].id == online) { + _peers[i].online = true; + } + } + }); + + evt['offlines'].split(',').forEach((offline) { + for (var i = 0; i < _peers.length; i++) { + if (_peers[i].id == offline) { + _peers[i].online = false; + } + } + }); + + notifyListeners(); + } +} diff --git a/flutter/macos/Runner/bridge_generated.h b/flutter/macos/Runner/bridge_generated.h index 163ad91cd..2d14efe93 100644 --- a/flutter/macos/Runner/bridge_generated.h +++ b/flutter/macos/Runner/bridge_generated.h @@ -17,6 +17,11 @@ typedef struct WireSyncReturnStruct { bool success; } WireSyncReturnStruct; +typedef struct wire_StringList { + struct wire_uint_8_list **ptr; + int32_t len; +} wire_StringList; + typedef int64_t DartPort; typedef bool (*DartPostCObjectFnType)(DartPort port_id, void *message); @@ -165,6 +170,82 @@ void wire_session_read_local_dir_sync(int64_t port_, struct wire_uint_8_list *path, bool show_hidden); +void wire_session_get_platform(int64_t port_, struct wire_uint_8_list *id, bool is_remote); + +void wire_session_load_last_transfer_jobs(int64_t port_, struct wire_uint_8_list *id); + +void wire_session_add_job(int64_t port_, + struct wire_uint_8_list *id, + int32_t act_id, + struct wire_uint_8_list *path, + struct wire_uint_8_list *to, + int32_t file_num, + bool include_hidden, + bool is_remote); + +void wire_session_resume_job(int64_t port_, + struct wire_uint_8_list *id, + int32_t act_id, + bool is_remote); + +void wire_main_get_sound_inputs(int64_t port_); + +void wire_main_change_id(int64_t port_, struct wire_uint_8_list *new_id); + +void wire_main_get_async_status(int64_t port_); + +void wire_main_get_options(int64_t port_); + +void wire_main_set_options(int64_t port_, struct wire_uint_8_list *json); + +void wire_main_test_if_valid_server(int64_t port_, struct wire_uint_8_list *server); + +void wire_main_set_socks(int64_t port_, + struct wire_uint_8_list *proxy, + struct wire_uint_8_list *username, + struct wire_uint_8_list *password); + +void wire_main_get_socks(int64_t port_); + +void wire_main_get_app_name(int64_t port_); + +void wire_main_get_license(int64_t port_); + +void wire_main_get_version(int64_t port_); + +void wire_main_get_fav(int64_t port_); + +void wire_main_store_fav(int64_t port_, struct wire_StringList *favs); + +void wire_main_get_peers(int64_t port_, struct wire_uint_8_list *id); + +void wire_main_get_lan_peers(int64_t port_); + +void wire_main_get_connect_status(int64_t port_); + +void wire_main_check_connect_status(int64_t port_); + +void wire_main_is_using_public_server(int64_t port_); + +void wire_main_has_rendezvous_service(int64_t port_); + +void wire_main_get_api_server(int64_t port_); + +void wire_main_post_request(int64_t port_, + struct wire_uint_8_list *url, + struct wire_uint_8_list *body, + struct wire_uint_8_list *header); + +void wire_main_get_local_option(int64_t port_, struct wire_uint_8_list *key); + +void wire_main_set_local_option(int64_t port_, + struct wire_uint_8_list *key, + struct wire_uint_8_list *value); + +void wire_query_onlines(int64_t port_, struct wire_StringList *ids); + +struct wire_StringList *new_StringList(int32_t len); + struct wire_uint_8_list *new_uint_8_list(int32_t len); void free_WireSyncReturnStruct(struct WireSyncReturnStruct val); @@ -213,6 +294,35 @@ static int64_t dummy_method_to_enforce_bundling(void) { dummy_var ^= ((int64_t) (void*) wire_session_cancel_job); dummy_var ^= ((int64_t) (void*) wire_session_create_dir); dummy_var ^= ((int64_t) (void*) wire_session_read_local_dir_sync); + dummy_var ^= ((int64_t) (void*) wire_session_get_platform); + dummy_var ^= ((int64_t) (void*) wire_session_load_last_transfer_jobs); + dummy_var ^= ((int64_t) (void*) wire_session_add_job); + dummy_var ^= ((int64_t) (void*) wire_session_resume_job); + dummy_var ^= ((int64_t) (void*) wire_main_get_sound_inputs); + dummy_var ^= ((int64_t) (void*) wire_main_change_id); + dummy_var ^= ((int64_t) (void*) wire_main_get_async_status); + dummy_var ^= ((int64_t) (void*) wire_main_get_options); + dummy_var ^= ((int64_t) (void*) wire_main_set_options); + dummy_var ^= ((int64_t) (void*) wire_main_test_if_valid_server); + dummy_var ^= ((int64_t) (void*) wire_main_set_socks); + dummy_var ^= ((int64_t) (void*) wire_main_get_socks); + dummy_var ^= ((int64_t) (void*) wire_main_get_app_name); + dummy_var ^= ((int64_t) (void*) wire_main_get_license); + dummy_var ^= ((int64_t) (void*) wire_main_get_version); + dummy_var ^= ((int64_t) (void*) wire_main_get_fav); + dummy_var ^= ((int64_t) (void*) wire_main_store_fav); + dummy_var ^= ((int64_t) (void*) wire_main_get_peers); + dummy_var ^= ((int64_t) (void*) wire_main_get_lan_peers); + dummy_var ^= ((int64_t) (void*) wire_main_get_connect_status); + dummy_var ^= ((int64_t) (void*) wire_main_check_connect_status); + dummy_var ^= ((int64_t) (void*) wire_main_is_using_public_server); + dummy_var ^= ((int64_t) (void*) wire_main_has_rendezvous_service); + dummy_var ^= ((int64_t) (void*) wire_main_get_api_server); + dummy_var ^= ((int64_t) (void*) wire_main_post_request); + dummy_var ^= ((int64_t) (void*) wire_main_get_local_option); + dummy_var ^= ((int64_t) (void*) wire_main_set_local_option); + dummy_var ^= ((int64_t) (void*) wire_query_onlines); + dummy_var ^= ((int64_t) (void*) new_StringList); dummy_var ^= ((int64_t) (void*) new_uint_8_list); dummy_var ^= ((int64_t) (void*) free_WireSyncReturnStruct); dummy_var ^= ((int64_t) (void*) store_dart_post_cobject); diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 364bad74d..127dcd523 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -771,6 +771,13 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.1.0" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.1.2" settings_ui: dependency: "direct main" description: @@ -1028,6 +1035,13 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.2" + visibility_detector: + dependency: "direct main" + description: + name: visibility_detector + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.3" wakelock: dependency: "direct main" description: @@ -1084,6 +1098,13 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.6.1" + window_manager: + dependency: "direct main" + description: + name: window_manager + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.5" xdg_directories: dependency: transitive description: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 4a2b64043..76c2f7e12 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -58,7 +58,7 @@ dependencies: url: https://github.com/SoLongAndThanksForAllThePizza/flutter_rust_bridge ref: master path: frb_dart - # window_manager: ^0.2.5 + window_manager: ^0.2.5 desktop_multi_window: git: url: https://github.com/Kingtous/rustdesk_desktop_multi_window @@ -67,6 +67,7 @@ dependencies: freezed_annotation: ^2.0.3 tray_manager: 0.1.7 get: ^4.6.5 + visibility_detector: ^0.3.3 contextmenu: ^3.0.0 dev_dependencies: diff --git a/libs/hbb_common/protos/rendezvous.proto b/libs/hbb_common/protos/rendezvous.proto index 2c5f1b3ba..1ac60f3f3 100644 --- a/libs/hbb_common/protos/rendezvous.proto +++ b/libs/hbb_common/protos/rendezvous.proto @@ -148,6 +148,15 @@ message PeerDiscovery { string misc = 7; } +message OnlineRequest { + string id = 1; + repeated string peers = 2; +} + +message OnlineResponse { + bytes states = 1; +} + message RendezvousMessage { oneof union { RegisterPeer register_peer = 6; @@ -167,5 +176,7 @@ message RendezvousMessage { TestNatRequest test_nat_request = 20; TestNatResponse test_nat_response = 21; PeerDiscovery peer_discovery = 22; + OnlineRequest online_request = 23; + OnlineResponse online_response = 24; } } diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 3d94f6cc7..57e7db87d 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -985,6 +985,21 @@ unsafe extern "C" fn set_by_name(name: *const c_char, value: *const c_char) { } } +fn handle_query_onlines(onlines: Vec, offlines: Vec) { + if let Some(s) = flutter::GLOBAL_EVENT_STREAM.read().unwrap().as_ref() { + let data = HashMap::from([ + ("name", "callback_query_onlines".to_owned()), + ("onlines", onlines.join(",")), + ("offlines", offlines.join(",")), + ]); + s.add(serde_json::ser::to_string(&data).unwrap_or("".to_owned())); + }; +} + +pub fn query_onlines(ids: Vec) { + crate::rendezvous_mediator::query_online_states(ids, handle_query_onlines) +} + #[cfg(target_os = "android")] pub mod server_side { use jni::{ diff --git a/src/rendezvous_mediator.rs b/src/rendezvous_mediator.rs index a7f90b977..09500804b 100644 --- a/src/rendezvous_mediator.rs +++ b/src/rendezvous_mediator.rs @@ -8,6 +8,7 @@ use hbb_common::{ protobuf::Message as _, rendezvous_proto::*, sleep, socket_client, + tcp::FramedStream, tokio::{ self, select, time::{interval, Duration}, @@ -637,3 +638,139 @@ pub fn discover() -> ResultType<()> { config::LanPeers::store(serde_json::to_string(&peers)?); Ok(()) } + +#[tokio::main(flavor = "current_thread")] +pub async fn query_online_states, Vec)>(ids: Vec, f: F) { + let test = false; + if test { + sleep(1.5).await; + let mut onlines = ids; + let offlines = onlines.drain((onlines.len() / 2)..).collect(); + f(onlines, offlines) + } else { + let query_begin = Instant::now(); + let query_timeout = std::time::Duration::from_millis(3_000); + loop { + if SHOULD_EXIT.load(Ordering::SeqCst) { + break; + } + match query_online_states_(&ids, query_timeout).await { + Ok((onlines, offlines)) => { + f(onlines, offlines); + break; + } + Err(e) => { + log::debug!("{}", &e); + } + } + + if query_begin.elapsed() > query_timeout { + log::debug!("query onlines timeout {:?}", query_timeout); + break; + } + + sleep(1.5).await; + } + } +} + +async fn create_online_stream() -> ResultType { + let rendezvous_server = crate::get_rendezvous_server(1_000).await; + let tmp: Vec<&str> = rendezvous_server.split(":").collect(); + if tmp.len() != 2 { + bail!("Invalid server address: {}", rendezvous_server); + } + let port: u16 = tmp[1].parse()?; + if port == 0 { + bail!("Invalid server address: {}", rendezvous_server); + } + let online_server = format!("{}:{}", tmp[0], port - 1); + let server_addr = socket_client::get_target_addr(&online_server)?; + socket_client::connect_tcp( + server_addr, + Config::get_any_listen_addr(), + RENDEZVOUS_TIMEOUT, + ) + .await +} + +async fn query_online_states_( + ids: &Vec, + timeout: std::time::Duration, +) -> ResultType<(Vec, Vec)> { + let query_begin = Instant::now(); + + let mut msg_out = RendezvousMessage::new(); + msg_out.set_online_request(OnlineRequest { + id: Config::get_id(), + peers: ids.clone(), + ..Default::default() + }); + + loop { + if SHOULD_EXIT.load(Ordering::SeqCst) { + // No need to care about onlines + return Ok((Vec::new(), Vec::new())); + } + + let mut socket = create_online_stream().await?; + socket.send(&msg_out).await?; + match socket.next_timeout(RENDEZVOUS_TIMEOUT).await { + Some(Ok(bytes)) => { + if let Ok(msg_in) = RendezvousMessage::parse_from_bytes(&bytes) { + match msg_in.union { + Some(rendezvous_message::Union::online_response(online_response)) => { + let states = online_response.states; + let mut onlines = Vec::new(); + let mut offlines = Vec::new(); + for i in 0..ids.len() { + // bytes index from left to right + let bit_value = 0x01 << (7 - i % 8); + if (states[i / 8] & bit_value) == bit_value { + onlines.push(ids[i].clone()); + } else { + offlines.push(ids[i].clone()); + } + } + return Ok((onlines, offlines)); + } + _ => { + // ignore + } + } + } + } + Some(Err(e)) => { + log::error!("Failed to receive {e}"); + } + None => { + // TODO: Make sure socket closed? + bail!("Online stream receives None"); + } + } + + if query_begin.elapsed() > timeout { + bail!("Try query onlines timeout {:?}", &timeout); + } + + sleep(300.0).await; + } +} + +#[cfg(test)] +mod tests { + #[test] + fn test_query_onlines() { + super::query_online_states( + vec![ + "152183996".to_owned(), + "165782066".to_owned(), + "155323351".to_owned(), + "460952777".to_owned(), + ], + |onlines: Vec, offlines: Vec| { + println!("onlines: {:?}, offlines: {:?}", &onlines, &offlines); + }, + ); + } +}