diff --git a/flutter/lib/desktop/pages/connection_page.dart b/flutter/lib/desktop/pages/connection_page.dart index 56523bee1..14a1cc4e7 100644 --- a/flutter/lib/desktop/pages/connection_page.dart +++ b/flutter/lib/desktop/pages/connection_page.dart @@ -3,6 +3,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/utils/multi_window_manager.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; @@ -588,7 +589,13 @@ class _ConnectionPageState extends State { svcIsUsingPublicServer.value = await gFFI.bind.mainIsUsingPublicServer(); } - handleLogin() {} + handleLogin() { + loginDialog().then((success) { + if (success) { + setState(() {}); + } + }); + } Future buildAddressBook(BuildContext context) async { final token = await gFFI.getLocalOption('access_token'); @@ -975,27 +982,6 @@ class _ConnectionPageState extends State { } } -class AddressBookPage extends StatefulWidget { - const AddressBookPage({Key? key}) : super(key: key); - - @override - State createState() => _AddressBookPageState(); -} - -class _AddressBookPageState extends State { - @override - void initState() { - // TODO: implement initState - final ab = gFFI.abModel.getAb(); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return Container(); - } -} - class WebMenu extends StatefulWidget { @override _WebMenuState createState() => _WebMenuState(); diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index 2152a60c3..47c066c9c 100644 --- a/flutter/lib/desktop/pages/desktop_home_page.dart +++ b/flutter/lib/desktop/pages/desktop_home_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -110,68 +111,18 @@ class _DesktopHomePageState extends State with TrayListener { style: TextStyle( fontSize: 18, fontWeight: FontWeight.w500), ), - PopupMenuButton( - padding: EdgeInsets.all(4.0), - itemBuilder: (context) => [ - genEnablePopupMenuItem( - translate("Enable Keyboard/Mouse"), - 'enable-keyboard', - ), - genEnablePopupMenuItem( - translate("Enable Clipboard"), - 'enable-clipboard', - ), - genEnablePopupMenuItem( - translate("Enable File Transfer"), - 'enable-file-transfer', - ), - genEnablePopupMenuItem( - translate("Enable TCP Tunneling"), - 'enable-tunnel', - ), - genAudioInputPopupMenuItem(), - // TODO: Audio Input - PopupMenuItem( - child: Text(translate("ID/Relay Server")), - value: 'custom-server', - ), - PopupMenuItem( - child: Text(translate("IP Whitelisting")), - value: 'whitelist', - ), - PopupMenuItem( - child: Text(translate("Socks5 Proxy")), - value: 'socks5-proxy', - ), - // sep - genEnablePopupMenuItem( - translate("Enable Service"), - 'stop-service', - ), - // TODO: direct server - genEnablePopupMenuItem( - translate("Always connected via relay"), - 'allow-always-relay', - ), - genEnablePopupMenuItem( - translate("Start ID/relay service"), - 'stop-rendezvous-service', - ), - PopupMenuItem( - child: Text(translate("Change ID")), - value: 'change-id', - ), - genEnablePopupMenuItem( - translate("Dark Theme"), - 'allow-darktheme', - ), - PopupMenuItem( - child: Text(translate("About")), - value: 'about', - ), - ], - onSelected: onSelectMenu, - ) + FutureBuilder( + future: buildPopupMenu(context), + builder: (context, snapshot) { + if (snapshot.hasError) { + print("${snapshot.error}"); + } + if (snapshot.hasData) { + return snapshot.data!; + } else { + return Offstage(); + } + }) ], ), TextFormField( @@ -189,6 +140,91 @@ class _DesktopHomePageState extends State with TrayListener { ); } + Future buildPopupMenu(BuildContext context) async { + var position; + return GestureDetector( + onTapDown: (detail) { + final x = detail.globalPosition.dx; + final y = detail.globalPosition.dy; + position = RelativeRect.fromLTRB(x, y, x, y); + }, + onTap: () async { + final userName = await gFFI.userModel.getUserName(); + var menu = [ + genEnablePopupMenuItem( + translate("Enable Keyboard/Mouse"), + 'enable-keyboard', + ), + genEnablePopupMenuItem( + translate("Enable Clipboard"), + 'enable-clipboard', + ), + genEnablePopupMenuItem( + translate("Enable File Transfer"), + 'enable-file-transfer', + ), + genEnablePopupMenuItem( + translate("Enable TCP Tunneling"), + 'enable-tunnel', + ), + genAudioInputPopupMenuItem(), + PopupMenuItem( + child: Text(translate("ID/Relay Server")), + value: 'custom-server', + ), + PopupMenuItem( + child: Text(translate("IP Whitelisting")), + value: 'whitelist', + ), + PopupMenuItem( + child: Text(translate("Socks5 Proxy")), + value: 'socks5-proxy', + ), + // sep + genEnablePopupMenuItem( + translate("Enable Service"), + 'stop-service', + ), + // TODO: direct server + genEnablePopupMenuItem( + translate("Always connected via relay"), + 'allow-always-relay', + ), + genEnablePopupMenuItem( + translate("Start ID/relay service"), + 'stop-rendezvous-service', + ), + userName.isEmpty + ? PopupMenuItem( + child: Text(translate("Login")), + value: 'login', + ) + : PopupMenuItem( + child: Text("${translate("Logout")} $userName"), + value: 'logout', + ), + PopupMenuItem( + child: Text(translate("Change ID")), + value: 'change-id', + ), + genEnablePopupMenuItem( + translate("Dark Theme"), + 'allow-darktheme', + ), + PopupMenuItem( + child: Text(translate("About")), + value: 'about', + ), + ]; + final v = + await showMenu(context: context, position: position, items: menu); + if (v != null) { + onSelectMenu(v); + } + }, + child: Icon(Icons.more_vert_outlined)); + } + buildPasswordBoard(BuildContext context) { final model = gFFI.serverModel; return Container( @@ -259,15 +295,15 @@ class _DesktopHomePageState extends State with TrayListener { Text(translate("Control Remote Desktop")), Form( child: Column( - children: [ - TextFormField( - controller: TextEditingController(), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r"[0-9]")) + children: [ + TextFormField( + controller: TextEditingController(), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r"[0-9]")) + ], + ) ], - ) - ], - )) + )) ], ), ); @@ -284,7 +320,7 @@ class _DesktopHomePageState extends State with TrayListener { case "quit": exit(0); case "show": - // windowManager.show(); + // windowManager.show(); break; default: break; @@ -323,6 +359,10 @@ class _DesktopHomePageState extends State with TrayListener { changeSocks5Proxy(); } else if (value == "about") { about(); + } else if (value == "logout") { + logOut(); + } else if (value == "login") { + login(); } } @@ -348,7 +388,7 @@ class _DesktopHomePageState extends State with TrayListener { return isPositive ? TextStyle() : TextStyle( - color: Colors.redAccent, decoration: TextDecoration.lineThrough); + color: Colors.redAccent, decoration: TextDecoration.lineThrough); } PopupMenuItem genAudioInputPopupMenuItem() { @@ -366,23 +406,23 @@ class _DesktopHomePageState extends State with TrayListener { } var inputList = inputs .map((e) => PopupMenuItem( - child: Row( - children: [ - Obx(() => Offstage( - offstage: defaultInput.value != e, - child: Icon(Icons.check))), - Expanded( - child: Tooltip( - message: e, - child: Text( - "$e", - maxLines: 1, - overflow: TextOverflow.ellipsis, - ))), - ], - ), - value: e, - )) + child: Row( + children: [ + Obx(() => Offstage( + offstage: defaultInput.value != e, + child: Icon(Icons.check))), + Expanded( + child: Tooltip( + message: e, + child: Text( + "$e", + maxLines: 1, + overflow: TextOverflow.ellipsis, + ))), + ], + ), + value: e, + )) .toList(); inputList.insert( 0, @@ -503,7 +543,7 @@ class _DesktopHomePageState extends State with TrayListener { void changeServer() async { Map oldOptions = - jsonDecode(await gFFI.bind.mainGetOptions()); + jsonDecode(await gFFI.bind.mainGetOptions()); print("${oldOptions}"); String idServer = oldOptions['custom-rendezvous-server'] ?? ""; var idServerMsg = ""; @@ -542,7 +582,7 @@ class _DesktopHomePageState extends State with TrayListener { decoration: InputDecoration( border: OutlineInputBorder(), errorText: - idServerMsg.isNotEmpty ? idServerMsg : null), + idServerMsg.isNotEmpty ? idServerMsg : null), controller: TextEditingController(text: idServer), ), ), @@ -595,7 +635,7 @@ class _DesktopHomePageState extends State with TrayListener { decoration: InputDecoration( border: OutlineInputBorder(), errorText: - apiServerMsg.isNotEmpty ? apiServerMsg : null), + apiServerMsg.isNotEmpty ? apiServerMsg : null), controller: TextEditingController(text: apiServer), ), ), @@ -711,7 +751,7 @@ class _DesktopHomePageState extends State with TrayListener { void changeWhiteList() async { Map oldOptions = - jsonDecode(await gFFI.bind.mainGetOptions()); + jsonDecode(await gFFI.bind.mainGetOptions()); var newWhiteList = ((oldOptions['whitelist'] ?? "") as String).split(','); var newWhiteListField = newWhiteList.join('\n'); var msg = ""; @@ -767,7 +807,7 @@ class _DesktopHomePageState extends State with TrayListener { // pass } else { final ips = - newWhiteListField.trim().split(RegExp(r"[\s,;\n]+")); + newWhiteListField.trim().split(RegExp(r"[\s,;\n]+")); // test ip final ipMatch = RegExp(r"^\d+\.\d+\.\d+\.\d+$"); for (final ip in ips) { @@ -832,8 +872,7 @@ class _DesktopHomePageState extends State with TrayListener { }, decoration: InputDecoration( border: OutlineInputBorder(), - errorText: - proxyMsg.isNotEmpty ? proxyMsg : null), + errorText: proxyMsg.isNotEmpty ? proxyMsg : null), controller: TextEditingController(text: proxy), ), ), @@ -857,8 +896,8 @@ class _DesktopHomePageState extends State with TrayListener { username = s; }, decoration: InputDecoration( - border: OutlineInputBorder(), - ), + border: OutlineInputBorder(), + ), controller: TextEditingController(text: username), ), ), @@ -882,8 +921,8 @@ class _DesktopHomePageState extends State with TrayListener { password = s; }, decoration: InputDecoration( - border: OutlineInputBorder(), - ), + border: OutlineInputBorder(), + ), controller: TextEditingController(text: password), ), ), @@ -941,9 +980,7 @@ class _DesktopHomePageState extends State with TrayListener { final appName = await gFFI.bind.mainGetAppName(); final license = await gFFI.bind.mainGetLicense(); final version = await gFFI.bind.mainGetVersion(); - final linkStyle = TextStyle( - decoration: TextDecoration.underline - ); + final linkStyle = TextStyle(decoration: TextDecoration.underline); DialogManager.show((setState, close) { return CustomAlertDialog( title: Text("About $appName"), @@ -960,16 +997,20 @@ class _DesktopHomePageState extends State with TrayListener { onTap: () { launchUrlString("https://rustdesk.com/privacy"); }, - child: Text("Privacy Statement", style: linkStyle,).marginSymmetric(vertical: 4.0)), + child: Text( + "Privacy Statement", + style: linkStyle, + ).marginSymmetric(vertical: 4.0)), InkWell( - onTap: () { - launchUrlString("https://rustdesk.com"); - } - ,child: Text("Website",style: linkStyle,).marginSymmetric(vertical: 4.0)), + onTap: () { + launchUrlString("https://rustdesk.com"); + }, + child: Text( + "Website", + style: linkStyle, + ).marginSymmetric(vertical: 4.0)), Container( - decoration: BoxDecoration( - color: Color(0xFF2c8cff) - ), + decoration: BoxDecoration(color: Color(0xFF2c8cff)), padding: EdgeInsets.symmetric(vertical: 24, horizontal: 8), child: Row( children: [ @@ -977,13 +1018,16 @@ class _DesktopHomePageState extends State with TrayListener { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text("Copyright © 2022 Purslane Ltd.\n$license", style: TextStyle( - color: Colors.white - ),), - Text("Made with heart in this chaotic world!", style: TextStyle( - fontWeight: FontWeight.w800, - color: Colors.white - ),) + Text( + "Copyright © 2022 Purslane Ltd.\n$license", + style: TextStyle(color: Colors.white), + ), + Text( + "Made with heart in this chaotic world!", + style: TextStyle( + fontWeight: FontWeight.w800, + color: Colors.white), + ) ], ), ), @@ -1003,4 +1047,151 @@ class _DesktopHomePageState extends State with TrayListener { ); }); } + + void login() { + loginDialog().then((success) { + if (success) { + // refresh frame + setState(() {}); + } + }); + } + + void logOut() { + gFFI.userModel.logOut().then((_) => {setState(() {})}); + } } + +/// common login dialog for desktop +/// call this directly +Future loginDialog() async { + String userName = ""; + var userNameMsg = ""; + String pass = ""; + var passMsg = ""; + + var isInProgress = false; + var completer = Completer(); + DialogManager.show((setState, close) { + return CustomAlertDialog( + title: Text(translate("Login")), + 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('Username')}:", + textAlign: TextAlign.start, + ).marginOnly(bottom: 16.0)), + SizedBox( + width: 24.0, + ), + Expanded( + child: TextField( + onChanged: (s) { + userName = s; + }, + decoration: InputDecoration( + border: OutlineInputBorder(), + errorText: userNameMsg.isNotEmpty ? userNameMsg : null), + controller: TextEditingController(text: userName), + ), + ), + ], + ), + 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: TextField( + obscureText: true, + onChanged: (s) { + pass = s; + }, + decoration: InputDecoration( + border: OutlineInputBorder(), + errorText: passMsg.isNotEmpty ? passMsg : null), + controller: TextEditingController(text: pass), + ), + ), + ], + ), + SizedBox( + height: 4.0, + ), + Offstage(offstage: !isInProgress, child: LinearProgressIndicator()) + ], + ), + ), + actions: [ + TextButton( + onPressed: () { + completer.complete(false); + close(); + }, + child: Text(translate("Cancel"))), + TextButton( + onPressed: () async { + setState(() { + userNameMsg = ""; + passMsg = ""; + isInProgress = true; + }); + final cancel = () { + setState(() { + isInProgress = false; + }); + }; + userName = userName; + pass = pass; + if (userName.isEmpty) { + userNameMsg = translate("Username missed"); + cancel(); + return; + } + if (pass.isEmpty) { + passMsg = translate("Password missed"); + cancel(); + return; + } + try { + final resp = await gFFI.userModel.login(userName, pass); + if (resp.containsKey('error')) { + passMsg = resp['error']; + cancel(); + return; + } + // {access_token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJndWlkIjoiMDFkZjQ2ZjgtZjg3OS00MDE0LTk5Y2QtMGMwYzM2MmViZGJlIiwiZXhwIjoxNjYxNDg2NzYwfQ.GZpe1oI8TfM5yTYNrpcwbI599P4Z_-b2GmnwNl2Lr-w, + // token_type: Bearer, user: {id: , name: admin, email: null, note: null, status: null, grp: null, is_admin: true}} + debugPrint("$resp"); + completer.complete(true); + } catch (err) { + print(err.toString()); + cancel(); + return; + } + close(); + }, + child: Text(translate("OK"))), + ], + ); + }); + return completer.future; +} \ No newline at end of file diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 322d9f300..bb6684438 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -8,6 +8,7 @@ import 'package:flutter_hbb/utils/multi_window_manager.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/route_manager.dart'; import 'package:provider/provider.dart'; + // import 'package:window_manager/window_manager.dart'; import 'common.dart'; @@ -77,6 +78,8 @@ class App extends StatelessWidget { ChangeNotifierProvider.value(value: gFFI.imageModel), ChangeNotifierProvider.value(value: gFFI.cursorModel), ChangeNotifierProvider.value(value: gFFI.canvasModel), + ChangeNotifierProvider.value(value: gFFI.abModel), + ChangeNotifierProvider.value(value: gFFI.userModel), ], child: GetMaterialApp( navigatorKey: globalKey, diff --git a/flutter/lib/models/ab_model.dart b/flutter/lib/models/ab_model.dart index 4350b6b05..165e3d8d1 100644 --- a/flutter/lib/models/ab_model.dart +++ b/flutter/lib/models/ab_model.dart @@ -140,4 +140,10 @@ class AbModel with ChangeNotifier { return it.first['tags'] ?? []; } } + + void clear() { + peers.clear(); + tags.clear(); + notifyListeners(); + } } diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index f401cf422..fa8210618 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -12,6 +12,7 @@ import 'package:flutter_hbb/models/ab_model.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/file_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; +import 'package:flutter_hbb/models/user_model.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:tuple/tuple.dart'; @@ -811,6 +812,7 @@ class FFI { late final ChatModel chatModel; late final FileModel fileModel; late final AbModel abModel; + late final UserModel userModel; FFI() { this.imageModel = ImageModel(WeakReference(this)); @@ -821,6 +823,7 @@ class FFI { this.chatModel = ChatModel(WeakReference(this)); this.fileModel = FileModel(WeakReference(this)); this.abModel = AbModel(WeakReference(this)); + this.userModel = UserModel(WeakReference(this)); } static FFI newFFI() { diff --git a/flutter/lib/models/user_model.dart b/flutter/lib/models/user_model.dart new file mode 100644 index 000000000..a842ec36e --- /dev/null +++ b/flutter/lib/models/user_model.dart @@ -0,0 +1,83 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:http/http.dart' as http; + +import 'model.dart'; + +class UserModel extends ChangeNotifier { + var userName = "".obs; + WeakReference parent; + + UserModel(this.parent); + + Future getUserName() async { + if (userName.isNotEmpty) { + return userName.value; + } + final userInfo = + await parent.target?.bind.mainGetLocalOption(key: 'user_info') ?? "{}"; + if (userInfo.trim().isEmpty) { + return ""; + } + final m = jsonDecode(userInfo); + userName.value = m['name'] ?? ''; + return userName.value; + } + + Future logOut() async { + debugPrint("start logout"); + final bind = parent.target?.bind; + if (bind == null) { + return; + } + final url = await bind.mainGetApiServer(); + final _ = await http.post(Uri.parse("$url/api/logout"), + body: { + "id": await bind.mainGetMyId(), + "uuid": await bind.mainGetUuid(), + }, + headers: await _getHeaders()); + await Future.wait([ + bind.mainSetLocalOption(key: 'access_token', value: ''), + bind.mainSetLocalOption(key: 'user_info', value: ''), + bind.mainSetLocalOption(key: 'selected-tags', value: ''), + ]); + parent.target?.abModel.clear(); + userName.value = ""; + notifyListeners(); + } + + Future>? _getHeaders() { + return parent.target?.getHttpHeaders(); + } + + Future> login(String userName, String pass) async { + final bind = parent.target?.bind; + if (bind == null) { + return {"error": "no context"}; + } + final url = await bind.mainGetApiServer(); + try { + final resp = await http.post(Uri.parse("$url/api/login"), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({ + "username": userName, + "password": pass, + "id": await bind.mainGetMyId(), + "uuid": await bind.mainGetUuid() + })); + final body = jsonDecode(resp.body); + bind.mainSetLocalOption( + key: "access_token", value: body['access_token'] ?? ""); + bind.mainSetLocalOption( + key: "user_info", value: jsonEncode(body['user'])); + this.userName.value = body['user']?['name'] ?? ""; + return body; + } catch (err) { + return {"error": "$err"}; + } + } +} diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index d38dd9529..3d94f6cc7 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -21,10 +21,10 @@ use crate::start_server; use crate::ui_interface; use crate::ui_interface::{ change_id, check_connect_status, get_api_server, get_app_name, get_async_job_status, - get_connect_status, get_fav, get_lan_peers, get_license, get_local_option, get_options, - get_peer, get_socks, get_sound_inputs, get_version, has_rendezvous_service, is_ok_change_id, - post_request, set_local_option, set_options, set_socks, store_fav, test_if_valid_server, - using_public_server, + get_connect_status, get_fav, get_id, get_lan_peers, get_license, get_local_option, get_options, + get_peer, get_socks, get_sound_inputs, get_uuid, get_version, has_rendezvous_service, + is_ok_change_id, post_request, set_local_option, set_options, set_socks, store_fav, + test_if_valid_server, using_public_server, }; fn initialize(app_dir: &str) { @@ -488,6 +488,14 @@ pub fn main_set_local_option(key: String, value: String) { set_local_option(key, value) } +pub fn main_get_my_id() -> String { + get_id() +} + +pub fn main_get_uuid() -> String { + get_uuid() +} + /// FFI for **get** commands which are idempotent. /// Return result in c string. ///