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); @override State createState() => _DesktopSettingPageState(); } 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) { 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