From 5887334c2e5118bbcfabe45ea093ca0ca70a99cb Mon Sep 17 00:00:00 2001 From: 21pages Date: Sat, 13 Aug 2022 12:43:35 +0800 Subject: [PATCH] add setting page Signed-off-by: 21pages --- flutter/lib/common.dart | 33 +- .../lib/desktop/pages/desktop_home_page.dart | 440 +------ .../desktop/pages/desktop_setting_page.dart | 1008 ++++++++++++++++- .../lib/desktop/pages/desktop_tab_page.dart | 2 +- flutter/lib/models/server_model.dart | 6 +- src/flutter_ffi.rs | 8 +- src/ui.rs | 25 +- src/ui_interface.rs | 4 + 8 files changed, 1066 insertions(+), 460 deletions(-) diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index cabd91b9e..aa5666e86 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -55,7 +55,6 @@ class MyTheme { bool isDarkTheme() { final isDark = "Y" == Get.find().getString("darkTheme"); - debugPrint("current is dark theme: $isDark"); return isDark; } @@ -482,3 +481,35 @@ String translate(String name) { } return platformFFI.translate(name, localeName); } + +bool option2bool(String key, String value) { + bool res; + if (key.startsWith("enable-")) { + res = value != "N"; + } else if (key.startsWith("allow-") || + key == "stop-service" || + key == "direct-server" || + key == "stop-rendezvous-service") { + res = value == "Y"; + } else { + assert(false); + res = value != "N"; + } + return res; +} + +String bool2option(String key, bool option) { + String res; + if (key.startsWith('enable-')) { + res = option ? '' : 'N'; + } else if (key.startsWith('allow-') || + key == "stop-service" || + key == "direct-server" || + key == "stop-rendezvous-service") { + res = option ? 'Y' : ''; + } else { + assert(false); + res = option ? 'Y' : 'N'; + } + return res; +} diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index f68cdac94..627c5b2e4 100644 --- a/flutter/lib/desktop/pages/desktop_home_page.dart +++ b/flutter/lib/desktop/pages/desktop_home_page.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart' hide MenuItem; import 'package:flutter/services.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/desktop/pages/connection_page.dart'; +import 'package:flutter_hbb/desktop/pages/desktop_setting_page.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; import 'package:flutter_hbb/utils/multi_window_manager.dart'; @@ -26,7 +27,10 @@ class DesktopHomePage extends StatefulWidget { const borderColor = Color(0xFF2F65BA); class _DesktopHomePageState extends State - with TrayListener, WindowListener { + with TrayListener, WindowListener, AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + @override void onWindowClose() async { super.onWindowClose(); @@ -678,440 +682,6 @@ class _DesktopHomePageState extends State }); } - void changeServer() async { - Map oldOptions = jsonDecode(await bind.mainGetOptions()); - print("${oldOptions}"); - String idServer = oldOptions['custom-rendezvous-server'] ?? ""; - var idServerMsg = ""; - String relayServer = oldOptions['relay-server'] ?? ""; - var relayServerMsg = ""; - String apiServer = oldOptions['api-server'] ?? ""; - var apiServerMsg = ""; - var key = oldOptions['key'] ?? ""; - - var isInProgress = false; - gFFI.dialogManager.show((setState, close) { - return CustomAlertDialog( - title: Text(translate("ID/Relay Server")), - 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('ID Server')}:") - .marginOnly(bottom: 16.0)), - SizedBox( - width: 24.0, - ), - Expanded( - child: TextField( - onChanged: (s) { - idServer = s; - }, - decoration: InputDecoration( - border: OutlineInputBorder(), - errorText: - idServerMsg.isNotEmpty ? idServerMsg : null), - controller: TextEditingController(text: idServer), - ), - ), - ], - ), - SizedBox( - height: 8.0, - ), - Row( - children: [ - ConstrainedBox( - constraints: BoxConstraints(minWidth: 100), - child: Text("${translate('Relay Server')}:") - .marginOnly(bottom: 16.0)), - SizedBox( - width: 24.0, - ), - Expanded( - child: TextField( - onChanged: (s) { - relayServer = s; - }, - decoration: InputDecoration( - border: OutlineInputBorder(), - errorText: relayServerMsg.isNotEmpty - ? relayServerMsg - : null), - controller: TextEditingController(text: relayServer), - ), - ), - ], - ), - SizedBox( - height: 8.0, - ), - Row( - children: [ - ConstrainedBox( - constraints: BoxConstraints(minWidth: 100), - child: Text("${translate('API Server')}:") - .marginOnly(bottom: 16.0)), - SizedBox( - width: 24.0, - ), - Expanded( - child: TextField( - onChanged: (s) { - apiServer = s; - }, - decoration: InputDecoration( - border: OutlineInputBorder(), - errorText: - apiServerMsg.isNotEmpty ? apiServerMsg : null), - controller: TextEditingController(text: apiServer), - ), - ), - ], - ), - SizedBox( - height: 8.0, - ), - Row( - children: [ - ConstrainedBox( - constraints: BoxConstraints(minWidth: 100), - child: Text("${translate('Key')}:") - .marginOnly(bottom: 16.0)), - SizedBox( - width: 24.0, - ), - Expanded( - child: TextField( - onChanged: (s) { - key = s; - }, - decoration: InputDecoration( - border: OutlineInputBorder(), - ), - controller: TextEditingController(text: key), - ), - ), - ], - ), - SizedBox( - height: 4.0, - ), - Offstage( - offstage: !isInProgress, child: LinearProgressIndicator()) - ], - ), - ), - actions: [ - TextButton( - onPressed: () { - close(); - }, - child: Text(translate("Cancel"))), - TextButton( - onPressed: () async { - setState(() { - [idServerMsg, relayServerMsg, apiServerMsg] - .forEach((element) { - element = ""; - }); - isInProgress = true; - }); - final cancel = () { - setState(() { - isInProgress = false; - }); - }; - idServer = idServer.trim(); - relayServer = relayServer.trim(); - apiServer = apiServer.trim(); - key = key.trim(); - - if (idServer.isNotEmpty) { - idServerMsg = translate( - await bind.mainTestIfValidServer(server: idServer)); - if (idServerMsg.isEmpty) { - oldOptions['custom-rendezvous-server'] = idServer; - } else { - cancel(); - return; - } - } else { - oldOptions['custom-rendezvous-server'] = ""; - } - - if (relayServer.isNotEmpty) { - relayServerMsg = translate( - await bind.mainTestIfValidServer(server: relayServer)); - if (relayServerMsg.isEmpty) { - oldOptions['relay-server'] = relayServer; - } else { - cancel(); - return; - } - } else { - oldOptions['relay-server'] = ""; - } - - if (apiServer.isNotEmpty) { - if (apiServer.startsWith('http://') || - apiServer.startsWith("https://")) { - oldOptions['api-server'] = apiServer; - return; - } else { - apiServerMsg = translate("invalid_http"); - cancel(); - return; - } - } else { - oldOptions['api-server'] = ""; - } - // ok - oldOptions['key'] = key; - await bind.mainSetOptions(json: jsonEncode(oldOptions)); - close(); - }, - child: Text(translate("OK"))), - ], - ); - }); - } - - void changeWhiteList() async { - Map oldOptions = jsonDecode(await bind.mainGetOptions()); - var newWhiteList = ((oldOptions['whitelist'] ?? "") as String).split(','); - var newWhiteListField = newWhiteList.join('\n'); - var msg = ""; - var isInProgress = false; - gFFI.dialogManager.show((setState, close) { - return CustomAlertDialog( - title: Text(translate("IP Whitelisting")), - content: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(translate("whitelist_sep")), - SizedBox( - height: 8.0, - ), - Row( - children: [ - Expanded( - child: TextField( - onChanged: (s) { - newWhiteListField = s; - }, - maxLines: null, - decoration: InputDecoration( - border: OutlineInputBorder(), - errorText: msg.isEmpty ? null : translate(msg), - ), - controller: TextEditingController(text: newWhiteListField), - ), - ), - ], - ), - SizedBox( - height: 4.0, - ), - Offstage(offstage: !isInProgress, child: LinearProgressIndicator()) - ], - ), - actions: [ - TextButton( - onPressed: () { - close(); - }, - child: Text(translate("Cancel"))), - TextButton( - onPressed: () async { - setState(() { - msg = ""; - isInProgress = true; - }); - newWhiteListField = newWhiteListField.trim(); - var newWhiteList = ""; - if (newWhiteListField.isEmpty) { - // pass - } else { - final ips = - newWhiteListField.trim().split(RegExp(r"[\s,;\n]+")); - // test ip - final ipMatch = RegExp(r"^\d+\.\d+\.\d+\.\d+$"); - for (final ip in ips) { - if (!ipMatch.hasMatch(ip)) { - msg = translate("Invalid IP") + " $ip"; - setState(() { - isInProgress = false; - }); - return; - } - } - newWhiteList = ips.join(','); - } - oldOptions['whitelist'] = newWhiteList; - await bind.mainSetOptions(json: jsonEncode(oldOptions)); - close(); - }, - child: Text(translate("OK"))), - ], - ); - }); - } - - void changeSocks5Proxy() async { - var socks = await bind.mainGetSocks(); - - String proxy = ""; - String proxyMsg = ""; - String username = ""; - String password = ""; - if (socks.length == 3) { - proxy = socks[0]; - username = socks[1]; - password = socks[2]; - } - - var isInProgress = false; - gFFI.dialogManager.show((setState, close) { - return CustomAlertDialog( - title: Text(translate("Socks5 Proxy")), - 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('Hostname')}:") - .marginOnly(bottom: 16.0)), - SizedBox( - width: 24.0, - ), - Expanded( - child: TextField( - onChanged: (s) { - proxy = s; - }, - decoration: InputDecoration( - border: OutlineInputBorder(), - errorText: proxyMsg.isNotEmpty ? proxyMsg : null), - controller: TextEditingController(text: proxy), - ), - ), - ], - ), - SizedBox( - height: 8.0, - ), - Row( - children: [ - ConstrainedBox( - constraints: BoxConstraints(minWidth: 100), - child: Text("${translate('Username')}:") - .marginOnly(bottom: 16.0)), - SizedBox( - width: 24.0, - ), - Expanded( - child: TextField( - onChanged: (s) { - username = s; - }, - decoration: InputDecoration( - border: OutlineInputBorder(), - ), - 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( - onChanged: (s) { - password = s; - }, - decoration: InputDecoration( - border: OutlineInputBorder(), - ), - controller: TextEditingController(text: password), - ), - ), - ], - ), - SizedBox( - height: 8.0, - ), - Offstage( - offstage: !isInProgress, child: LinearProgressIndicator()) - ], - ), - ), - actions: [ - TextButton( - onPressed: () { - close(); - }, - child: Text(translate("Cancel"))), - TextButton( - onPressed: () async { - setState(() { - proxyMsg = ""; - isInProgress = true; - }); - final cancel = () { - setState(() { - isInProgress = false; - }); - }; - proxy = proxy.trim(); - username = username.trim(); - password = password.trim(); - - if (proxy.isNotEmpty) { - proxyMsg = translate( - await bind.mainTestIfValidServer(server: proxy)); - if (proxyMsg.isEmpty) { - // ignore - } else { - cancel(); - return; - } - } - await bind.mainSetSocks( - proxy: proxy, username: username, password: password); - close(); - }, - child: Text(translate("OK"))), - ], - ); - }); - } - void about() async { final appName = await bind.mainGetAppName(); final license = await bind.mainGetLicense(); diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index 4d9a58f3b..0da3dcc50 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -1,4 +1,20 @@ -import 'package:flutter/cupertino.dart'; +import 'dart:convert'; +import 'dart:io' show Platform; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; +import 'package:flutter_hbb/models/server_model.dart'; +import 'package:get/get.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const double _kCardFixedWidth = 600; +const double _kCardLeftPadding = 20; +const double _kContentLeftPadding = 30; +const double _kListViewBottomPadding = 30; class DesktopSettingPage extends StatefulWidget { DesktopSettingPage({Key? key}) : super(key: key); @@ -7,9 +23,995 @@ class DesktopSettingPage extends StatefulWidget { State createState() => _DesktopSettingPageState(); } -class _DesktopSettingPageState extends State { +class _DesktopSettingPageState extends State + with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { + final List _destinations = + [ + _destination('Display', Icons.palette_outlined, Icons.palette), + _destination( + 'Security', Icons.health_and_safety_outlined, Icons.health_and_safety), + _destination( + 'Connection', Icons.settings_remote_outlined, Icons.settings_remote), + _destination('Video', Icons.videocam_outlined, Icons.videocam), + _destination('Audio', Icons.volume_up_outlined, Icons.volume_up), + ]; + + late TabController controller; + int _selectedIndex = 0; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + controller = TabController(length: _destinations.length, vsync: this); + } + @override Widget build(BuildContext context) { - return Text("Settings"); + super.build(context); + return Scaffold( + body: Row( + children: [ + NavigationRail( + selectedIndex: _selectedIndex, + onDestinationSelected: (int index) { + setState(() { + _selectedIndex = index; + }); + controller.animateTo(index); + }, + labelType: NavigationRailLabelType.all, + destinations: _destinations, + ), + const VerticalDivider(thickness: 1, width: 1), + Expanded( + child: TabBarView( + controller: controller, + children: [ + _Display(), + _Safety(), + _Connection(), + _Video(), + _Audio(), + ], + ), + ) + ], + ), + ); + } + + static NavigationRailDestination _destination( + String label, IconData selected, IconData unSelected) { + return NavigationRailDestination( + icon: Icon(unSelected), + selectedIcon: Icon(selected), + label: Text(translate(label)), + ); } } + +//#region pages + +class _Display extends StatefulWidget { + _Display({Key? key}) : super(key: key); + + @override + State<_Display> createState() => _DisplayState(); +} + +class _DisplayState extends State<_Display> with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return ListView( + children: [ + _Card(title: translate('Display'), children: [language(), theme()]), + ], + ).paddingOnly(bottom: _kListViewBottomPadding); + } + + Widget language() { + return _futureBuilder(future: () async { + String langs = await bind.mainGetLangs(); + String lang = await bind.mainGetLocalOption(key: "lang"); + return {"langs": langs, "lang": lang}; + }(), hasData: (res) { + Map data = res as Map; + List langsList = jsonDecode(data["langs"]!); + Map langsMap = {for (var v in langsList) v[0]: v[1]}; + List keys = langsMap.keys.toList(); + List values = langsMap.values.toList(); + keys.insert(0, "default"); + values.insert(0, "Default"); + String currentKey = data["lang"]!; + if (!keys.contains(currentKey)) { + currentKey = "default"; + } + return _row( + 'Language', + _ComboBox( + keys: keys, + values: values, + initialKey: currentKey, + onChanged: (key) async { + await bind.mainSetLocalOption(key: "lang", value: key); + Get.forceAppUpdate(); + }, + )); + }); + } + + Widget theme() { + return _row( + 'Dark Theme', + Switch( + value: isDarkTheme(), + onChanged: ((dark) async { + Get.changeTheme(dark ? MyTheme.darkTheme : MyTheme.lightTheme); + Get.find() + .setString("darkTheme", dark ? "Y" : ""); + Get.forceAppUpdate(); + }))); + } +} + +class _Safety extends StatefulWidget { + const _Safety({Key? key}) : super(key: key); + + @override + State<_Safety> createState() => _SafetyState(); +} + +class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return ListView( + children: [ + permissions(), + password(), + whitelist(), + ], + ).paddingOnly(bottom: _kListViewBottomPadding); + } + + Widget permissions() { + return _Card(title: 'Permissions', children: [ + _option_check('Enable Keyboard/Mouse', 'enable-keyboard'), + _option_check('Enable Clipboard', 'enable-clipboard'), + _option_check('Enable File Transfer', 'enable-file-transfer'), + _option_check('Enable Audio', 'enable-audio'), + _option_check('Enable Remote Restart', 'enable-remote-restart'), + _option_check('Enable remote configuration modification', + 'allow-remote-config-modification'), + ]); + } + + Widget password() { + return ChangeNotifierProvider.value( + value: gFFI.serverModel, + child: Consumer( + builder: ((context, model, child) => + _Card(title: 'Password', children: [ + _row( + 'Verification Method', + _ComboBox( + keys: [ + kUseTemporaryPassword, + kUsePermanentPassword, + kUseBothPasswords, + ], + values: [ + translate("Use temporary password"), + translate("Use permanent password"), + translate("Use both passwords"), + ], + initialKey: model.verificationMethod, + onChanged: (key) => model.verificationMethod = key)), + _row( + 'Temporary Password Length', + _ComboBox( + keys: ['6', '8', '10'], + values: ['6', '8', '10'], + initialKey: model.temporaryPasswordLength, + onChanged: (key) => model.temporaryPasswordLength = key, + enabled: + model.verificationMethod != kUsePermanentPassword, + )), + _button( + 'permanent_password_tip', + 'Set permanent password', + setPasswordDialog, + model.verificationMethod != kUseTemporaryPassword) + ])))); + } + + Widget whitelist() { + return _Card(title: 'IP Whitelisting', children: [ + _button('whitelist_tip', 'IP Whitelisting', changeWhiteList) + ]); + } +} + +class _Connection extends StatefulWidget { + const _Connection({Key? key}) : super(key: key); + + @override + State<_Connection> createState() => _ConnectionState(); +} + +class _ConnectionState extends State<_Connection> + with AutomaticKeepAliveClientMixin { + final TextEditingController controller = TextEditingController(); + + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return ListView( + children: [ + _Card(title: 'Server', children: [ + _button('self-hosting_tip', 'ID/Relay Server', changeServer), + ]), + _Card(title: 'Service', children: [ + _option_check('Enable Service', 'stop-service', reverse: true), + // TODO: Not implemented + // _option_check('Always connected via relay', 'allow-always-relay'), + // _option_check('Start ID/relay service', 'stop-rendezvous-service', + // reverse: true), + ]), + _Card(title: 'TCP Tunneling', children: [ + _option_check('Enable TCP Tunneling', 'enable-tunnel'), + ]), + direct_ip(), + _Card(title: 'Proxy', children: [ + _button('socks5_proxy_tip', 'Socks5 Proxy', changeSocks5Proxy), + ]), + ], + ).paddingOnly(bottom: _kListViewBottomPadding); + } + + Widget direct_ip() { + var update = () => setState(() {}); + return _Card(title: 'Direct IP Access', children: [ + _option_check('Enable Direct IP Access', 'direct-server', update: update), + _row( + 'Port', + _futureBuilder( + future: () async { + String enabled = await bind.mainGetOption(key: 'direct-server'); + String port = await bind.mainGetOption(key: 'direct-access-port'); + return {'enabled': enabled, 'port': port}; + }(), + hasData: (data) { + bool enabled = + option2bool('direct-server', data['enabled'].toString()); + String port = data['port'].toString(); + int? iport = int.tryParse(port); + if (iport == null || iport < 1 || iport > 65535) { + port = ''; + } + controller.text = port; + return TextField( + controller: controller, + enabled: enabled, + onChanged: (value) async { + await bind.mainSetOption( + key: 'direct-access-port', value: controller.text); + }, + decoration: InputDecoration( + hintText: '21118', + ), + ); + }, + ), + ), + ]); + } +} + +class _Video extends StatefulWidget { + const _Video({Key? key}) : super(key: key); + + @override + State<_Video> createState() => _VideoState(); +} + +class _VideoState extends State<_Video> with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return ListView( + children: [ + _Card(title: 'Adaptive Bitrate', children: [ + _option_check('Adaptive Bitrate', 'enable-abr'), + ]), + ], + ).paddingOnly(bottom: _kListViewBottomPadding); + } +} + +class _Audio extends StatefulWidget { + const _Audio({Key? key}) : super(key: key); + + @override + State<_Audio> createState() => _AudioState(); +} + +class _AudioState extends State<_Audio> with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + var update = () => setState(() {}); + return ListView(children: [ + _Card( + title: 'Audio Input', + children: [ + _option_check('Mute', 'enable-audio', reverse: true, update: update), + _row( + 'Audio device', + _futureBuilder(future: () async { + List all = await bind.mainGetSoundInputs(); + String current = await bind.mainGetOption(key: 'audio-input'); + String enabled = await bind.mainGetOption(key: 'enable-audio'); + return {'all': all, 'current': current, 'enabled': enabled}; + }(), hasData: (data) { + List keys = (data['all'] as List).toList(); + List values = keys.toList(); + if (Platform.isWindows) { + keys.insert(0, ''); + values.insert(0, 'System Sound'); + } else { + keys.insert(0, ''); // TODO + values.insert(0, 'None'); + } + String initialKey = data['current']; + if (!keys.contains(initialKey)) { + initialKey = ''; + } + return _ComboBox( + keys: keys, + values: values, + initialKey: initialKey, + onChanged: (key) { + bind.mainSetOption(key: 'audio-input', value: key); + }, + enabled: + option2bool('enable-audio', data['enabled'].toString()), + ); + })), + ], + ) + ]).paddingOnly(bottom: _kListViewBottomPadding); + } +} + +//#endregion + +//#region components + +Widget _Card({required String title, required List children}) { + return Row( + children: [ + Container( + width: _kCardFixedWidth, + child: Card( + child: Column( + children: [ + Row( + children: [ + Text( + translate(title), + textAlign: TextAlign.start, + style: TextStyle( + fontSize: 25, + ), + ), + Spacer(), + ], + ).paddingOnly(left: _kContentLeftPadding, top: 10, bottom: 20), + ...children.map((e) => e.paddingOnly(top: 2)), + ], + ).paddingOnly(bottom: 10), + ).paddingOnly(left: _kCardLeftPadding, top: 20), + ), + ], + ); +} + +Widget _option_switch(String label, String key, + {Function()? update = null, bool reverse = false}) { + return _row( + label, + _futureBuilder( + future: bind.mainGetOption(key: key), + hasData: (data) { + bool value = option2bool(key, data.toString()); + if (reverse) value = !value; + var ref = value.obs; + return Obx((() => Switch( + value: ref.value, + onChanged: ((option) async { + ref.value = option; + if (reverse) option = !option; + String value = bool2option(key, option); + bind.mainSetOption(key: key, value: value); + update?.call(); + })))); + })); +} + +Widget _option_check(String label, String key, + {Function()? update = null, bool reverse = false}) { + return Row(children: [ + _futureBuilder( + future: bind.mainGetOption(key: key), + hasData: (data) { + bool value = option2bool(key, data.toString()); + if (reverse) value = !value; + var ref = value.obs; + return Obx((() => Checkbox( + value: ref.value, + onChanged: ((option) async { + if (option != null) { + ref.value = option; + if (reverse) option = !option; + String value = bool2option(key, option); + bind.mainSetOption(key: key, value: value); + update?.call(); + } + })))); + }).paddingOnly(right: 10), + Text(translate(label)), + ]).paddingOnly(left: _kContentLeftPadding); +} + +Widget _button(String tip, String label, Function() onPressed, + [bool enabled = true]) { + return _row( + translate(tip), + OutlinedButton( + onPressed: enabled ? onPressed : null, + child: Text( + translate(label), + ))); +} + +Widget _row(String label, Widget widget) { + return Row( + children: [ + Expanded( + child: Text( + translate(label), + )), + SizedBox( + width: 40, + ), + Expanded(child: widget), + ], + ).paddingSymmetric(horizontal: _kContentLeftPadding); +} + +Widget _futureBuilder( + {required Future? future, required Widget Function(dynamic data) hasData}) { + return FutureBuilder( + future: future, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasData) { + return hasData(snapshot.data!); + } else { + if (snapshot.hasError) { + print(snapshot.error.toString()); + } + return Container(); + } + }); +} + +class _ComboBox extends StatelessWidget { + late final List keys; + late final List values; + late final String initialKey; + late final Function(String key) onChanged; + late final bool enabled; + + _ComboBox({ + Key? key, + required this.keys, + required this.values, + required this.initialKey, + required this.onChanged, + this.enabled = true, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + var index = keys.indexOf(initialKey); + if (index < 0) { + assert(false); + index = 0; + } + var ref = values[index].obs; + return Container( + child: SizedBox( + child: Obx((() => DropdownButton( + isExpanded: true, + value: ref.value, + elevation: 16, + underline: Container( + height: 40, + ), + icon: Icon( + Icons.arrow_drop_down_sharp, + size: 35, + ), + onChanged: enabled + ? (String? newValue) { + if (newValue != null && newValue != ref.value) { + ref.value = newValue; + onChanged(keys[values.indexOf(newValue)]); + } + } + : null, + items: values.map>((String value) { + return DropdownMenuItem( + value: value, + child: Text(value), + ); + }).toList(), + )))), + ); + } +} + +//#endregion + +//#region dialogs + +void changeServer() async { + Map oldOptions = jsonDecode(await bind.mainGetOptions()); + print("${oldOptions}"); + String idServer = oldOptions['custom-rendezvous-server'] ?? ""; + var idServerMsg = ""; + String relayServer = oldOptions['relay-server'] ?? ""; + var relayServerMsg = ""; + String apiServer = oldOptions['api-server'] ?? ""; + var apiServerMsg = ""; + var key = oldOptions['key'] ?? ""; + + var isInProgress = false; + gFFI.dialogManager.show((setState, close) { + return CustomAlertDialog( + title: Text(translate("ID/Relay Server")), + 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('ID Server')}:") + .marginOnly(bottom: 16.0)), + SizedBox( + width: 24.0, + ), + Expanded( + child: TextField( + onChanged: (s) { + idServer = s; + }, + decoration: InputDecoration( + border: OutlineInputBorder(), + errorText: idServerMsg.isNotEmpty ? idServerMsg : null), + controller: TextEditingController(text: idServer), + ), + ), + ], + ), + SizedBox( + height: 8.0, + ), + Row( + children: [ + ConstrainedBox( + constraints: BoxConstraints(minWidth: 100), + child: Text("${translate('Relay Server')}:") + .marginOnly(bottom: 16.0)), + SizedBox( + width: 24.0, + ), + Expanded( + child: TextField( + onChanged: (s) { + relayServer = s; + }, + decoration: InputDecoration( + border: OutlineInputBorder(), + errorText: + relayServerMsg.isNotEmpty ? relayServerMsg : null), + controller: TextEditingController(text: relayServer), + ), + ), + ], + ), + SizedBox( + height: 8.0, + ), + Row( + children: [ + ConstrainedBox( + constraints: BoxConstraints(minWidth: 100), + child: Text("${translate('API Server')}:") + .marginOnly(bottom: 16.0)), + SizedBox( + width: 24.0, + ), + Expanded( + child: TextField( + onChanged: (s) { + apiServer = s; + }, + decoration: InputDecoration( + border: OutlineInputBorder(), + errorText: + apiServerMsg.isNotEmpty ? apiServerMsg : null), + controller: TextEditingController(text: apiServer), + ), + ), + ], + ), + SizedBox( + height: 8.0, + ), + Row( + children: [ + ConstrainedBox( + constraints: BoxConstraints(minWidth: 100), + child: + Text("${translate('Key')}:").marginOnly(bottom: 16.0)), + SizedBox( + width: 24.0, + ), + Expanded( + child: TextField( + onChanged: (s) { + key = s; + }, + decoration: InputDecoration( + border: OutlineInputBorder(), + ), + controller: TextEditingController(text: key), + ), + ), + ], + ), + SizedBox( + height: 4.0, + ), + Offstage(offstage: !isInProgress, child: LinearProgressIndicator()) + ], + ), + ), + actions: [ + TextButton( + onPressed: () { + close(); + }, + child: Text(translate("Cancel"))), + TextButton( + onPressed: () async { + setState(() { + [idServerMsg, relayServerMsg, apiServerMsg].forEach((element) { + element = ""; + }); + isInProgress = true; + }); + final cancel = () { + setState(() { + isInProgress = false; + }); + }; + idServer = idServer.trim(); + relayServer = relayServer.trim(); + apiServer = apiServer.trim(); + key = key.trim(); + + if (idServer.isNotEmpty) { + idServerMsg = translate( + await bind.mainTestIfValidServer(server: idServer)); + if (idServerMsg.isEmpty) { + oldOptions['custom-rendezvous-server'] = idServer; + } else { + cancel(); + return; + } + } else { + oldOptions['custom-rendezvous-server'] = ""; + } + + if (relayServer.isNotEmpty) { + relayServerMsg = translate( + await bind.mainTestIfValidServer(server: relayServer)); + if (relayServerMsg.isEmpty) { + oldOptions['relay-server'] = relayServer; + } else { + cancel(); + return; + } + } else { + oldOptions['relay-server'] = ""; + } + + if (apiServer.isNotEmpty) { + if (apiServer.startsWith('http://') || + apiServer.startsWith("https://")) { + oldOptions['api-server'] = apiServer; + return; + } else { + apiServerMsg = translate("invalid_http"); + cancel(); + return; + } + } else { + oldOptions['api-server'] = ""; + } + // ok + oldOptions['key'] = key; + await bind.mainSetOptions(json: jsonEncode(oldOptions)); + close(); + }, + child: Text(translate("OK"))), + ], + ); + }); +} + +void changeWhiteList() async { + Map oldOptions = jsonDecode(await bind.mainGetOptions()); + var newWhiteList = ((oldOptions['whitelist'] ?? "") as String).split(','); + var newWhiteListField = newWhiteList.join('\n'); + var msg = ""; + var isInProgress = false; + gFFI.dialogManager.show((setState, close) { + return CustomAlertDialog( + title: Text(translate("IP Whitelisting")), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(translate("whitelist_sep")), + SizedBox( + height: 8.0, + ), + Row( + children: [ + Expanded( + child: TextField( + onChanged: (s) { + newWhiteListField = s; + }, + maxLines: null, + decoration: InputDecoration( + border: OutlineInputBorder(), + errorText: msg.isEmpty ? null : translate(msg), + ), + controller: TextEditingController(text: newWhiteListField), + ), + ), + ], + ), + SizedBox( + height: 4.0, + ), + Offstage(offstage: !isInProgress, child: LinearProgressIndicator()) + ], + ), + actions: [ + TextButton( + onPressed: () { + close(); + }, + child: Text(translate("Cancel"))), + TextButton( + onPressed: () async { + setState(() { + msg = ""; + isInProgress = true; + }); + newWhiteListField = newWhiteListField.trim(); + var newWhiteList = ""; + if (newWhiteListField.isEmpty) { + // pass + } else { + final ips = + newWhiteListField.trim().split(RegExp(r"[\s,;\n]+")); + // test ip + final ipMatch = RegExp(r"^\d+\.\d+\.\d+\.\d+$"); + for (final ip in ips) { + if (!ipMatch.hasMatch(ip)) { + msg = translate("Invalid IP") + " $ip"; + setState(() { + isInProgress = false; + }); + return; + } + } + newWhiteList = ips.join(','); + } + oldOptions['whitelist'] = newWhiteList; + await bind.mainSetOptions(json: jsonEncode(oldOptions)); + close(); + }, + child: Text(translate("OK"))), + ], + ); + }); +} + +void changeSocks5Proxy() async { + var socks = await bind.mainGetSocks(); + + String proxy = ""; + String proxyMsg = ""; + String username = ""; + String password = ""; + if (socks.length == 3) { + proxy = socks[0]; + username = socks[1]; + password = socks[2]; + } + + var isInProgress = false; + gFFI.dialogManager.show((setState, close) { + return CustomAlertDialog( + title: Text(translate("Socks5 Proxy")), + 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('Hostname')}:") + .marginOnly(bottom: 16.0)), + SizedBox( + width: 24.0, + ), + Expanded( + child: TextField( + onChanged: (s) { + proxy = s; + }, + decoration: InputDecoration( + border: OutlineInputBorder(), + errorText: proxyMsg.isNotEmpty ? proxyMsg : null), + controller: TextEditingController(text: proxy), + ), + ), + ], + ), + SizedBox( + height: 8.0, + ), + Row( + children: [ + ConstrainedBox( + constraints: BoxConstraints(minWidth: 100), + child: Text("${translate('Username')}:") + .marginOnly(bottom: 16.0)), + SizedBox( + width: 24.0, + ), + Expanded( + child: TextField( + onChanged: (s) { + username = s; + }, + decoration: InputDecoration( + border: OutlineInputBorder(), + ), + 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( + onChanged: (s) { + password = s; + }, + decoration: InputDecoration( + border: OutlineInputBorder(), + ), + controller: TextEditingController(text: password), + ), + ), + ], + ), + SizedBox( + height: 8.0, + ), + Offstage(offstage: !isInProgress, child: LinearProgressIndicator()) + ], + ), + ), + actions: [ + TextButton( + onPressed: () { + close(); + }, + child: Text(translate("Cancel"))), + TextButton( + onPressed: () async { + setState(() { + proxyMsg = ""; + isInProgress = true; + }); + final cancel = () { + setState(() { + isInProgress = false; + }); + }; + proxy = proxy.trim(); + username = username.trim(); + password = password.trim(); + + if (proxy.isNotEmpty) { + proxyMsg = + translate(await bind.mainTestIfValidServer(server: proxy)); + if (proxyMsg.isEmpty) { + // ignore + } else { + cancel(); + return; + } + } + await bind.mainSetSocks( + proxy: proxy, username: username, password: password); + close(); + }, + child: Text(translate("OK"))), + ], + ); + }); +} + +//#endregion diff --git a/flutter/lib/desktop/pages/desktop_tab_page.dart b/flutter/lib/desktop/pages/desktop_tab_page.dart index 24611e439..65ba37e45 100644 --- a/flutter/lib/desktop/pages/desktop_tab_page.dart +++ b/flutter/lib/desktop/pages/desktop_tab_page.dart @@ -68,6 +68,6 @@ class _DesktopTabPageState extends State void onAddSetting() { DesktopTabBar.onAdd(this, tabController, tabs, _selected, - TabInfo(label: kTabLabelSettingPage, icon: Icons.settings)); + TabInfo(label: kTabLabelSettingPage, icon: Icons.build)); } } diff --git a/flutter/lib/models/server_model.dart b/flutter/lib/models/server_model.dart index 6ed048dd4..3da823c09 100644 --- a/flutter/lib/models/server_model.dart +++ b/flutter/lib/models/server_model.dart @@ -51,26 +51,24 @@ class ServerModel with ChangeNotifier { kUseBothPasswords ].indexOf(_verificationMethod); if (index < 0) { - _verificationMethod = kUseBothPasswords; + return kUseBothPasswords; } return _verificationMethod; } set verificationMethod(String method) { - _verificationMethod = method; bind.mainSetOption(key: "verification-method", value: method); } String get temporaryPasswordLength { final lengthIndex = ["6", "8", "10"].indexOf(_temporaryPasswordLength); if (lengthIndex < 0) { - _temporaryPasswordLength = "6"; + return "6"; } return _temporaryPasswordLength; } set temporaryPasswordLength(String length) { - _temporaryPasswordLength = length; bind.mainSetOption(key: "temporary-password-length", value: length); } diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 95cd1abd3..4d062ab11 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -23,9 +23,9 @@ use crate::ui_interface; use crate::ui_interface::{change_id, check_connect_status, is_ok_change_id}; use crate::ui_interface::{ discover, forget_password, get_api_server, get_app_name, get_async_job_status, - get_connect_status, get_fav, get_id, get_lan_peers, get_license, get_local_option, get_option, - get_options, get_peer, get_peer_option, get_socks, get_sound_inputs, get_uuid, get_version, - has_rendezvous_service, post_request, set_local_option, set_option, set_options, + get_connect_status, get_fav, get_id, get_lan_peers, get_langs, get_license, get_local_option, + get_option, get_options, get_peer, get_peer_option, get_socks, get_sound_inputs, get_uuid, + get_version, has_rendezvous_service, post_request, set_local_option, set_option, set_options, set_peer_option, set_permanent_password, set_socks, store_fav, test_if_valid_server, update_temporary_password, using_public_server, }; @@ -614,7 +614,7 @@ pub fn main_get_home_dir() -> String { } pub fn main_get_langs() -> String { - crate::lang::LANGS.to_string() + get_langs() } pub fn main_get_temporary_password() -> String { diff --git a/src/ui.rs b/src/ui.rs index 284c3c55d..6484abbe5 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -24,17 +24,18 @@ use crate::ipc; use crate::ui_interface::{ check_mouse_time, closing, create_shortcut, current_is_wayland, fix_login_wayland, forget_password, get_api_server, get_async_job_status, get_connect_status, get_error, get_fav, - get_icon, get_lan_peers, get_license, get_local_option, get_mouse_time, get_new_version, - get_option, get_options, get_peer, get_peer_option, get_recent_sessions, get_remote_id, - get_size, get_socks, get_software_ext, get_software_store_path, get_software_update_url, - get_uuid, get_version, goto_install, has_rendezvous_service, install_me, install_path, - is_can_screen_recording, is_installed, is_installed_daemon, is_installed_lower_version, - is_login_wayland, is_ok_change_id, is_process_trusted, is_rdp_service_open, is_share_rdp, - is_xfce, modify_default_login, new_remote, open_url, peer_has_password, permanent_password, - post_request, recent_sessions_updated, remove_peer, run_without_install, set_local_option, - set_option, set_options, set_peer_option, set_permanent_password, set_remote_id, set_share_rdp, - set_socks, show_run_without_install, store_fav, t, temporary_password, test_if_valid_server, - update_me, update_temporary_password, using_public_server, + get_icon, get_lan_peers, get_langs, get_license, get_local_option, get_mouse_time, + get_new_version, get_option, get_options, get_peer, get_peer_option, get_recent_sessions, + get_remote_id, get_size, get_socks, get_software_ext, get_software_store_path, + get_software_update_url, get_uuid, get_version, goto_install, has_rendezvous_service, + install_me, install_path, is_can_screen_recording, is_installed, is_installed_daemon, + is_installed_lower_version, is_login_wayland, is_ok_change_id, is_process_trusted, + is_rdp_service_open, is_share_rdp, is_xfce, modify_default_login, new_remote, open_url, + peer_has_password, permanent_password, post_request, recent_sessions_updated, remove_peer, + run_without_install, set_local_option, set_option, set_options, set_peer_option, + set_permanent_password, set_remote_id, set_share_rdp, set_socks, show_run_without_install, + store_fav, t, temporary_password, test_if_valid_server, update_me, update_temporary_password, + using_public_server, }; mod cm; @@ -547,7 +548,7 @@ impl UI { } fn get_langs(&self) -> String { - crate::lang::LANGS.to_string() + get_langs() } } diff --git a/src/ui_interface.rs b/src/ui_interface.rs index cdfd0edce..b882507c9 100644 --- a/src/ui_interface.rs +++ b/src/ui_interface.rs @@ -654,6 +654,10 @@ pub fn t(name: String) -> String { crate::client::translate(name) } +pub fn get_langs() -> String { + crate::lang::LANGS.to_string() +} + pub fn is_xfce() -> bool { crate::platform::is_xfce() }