rustdesk/flutter/lib/desktop/pages/desktop_setting_page.dart

1018 lines
31 KiB
Dart
Raw Normal View History

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<DesktopSettingPage> createState() => _DesktopSettingPageState();
}
class _DesktopSettingPageState extends State<DesktopSettingPage>
with TickerProviderStateMixin, AutomaticKeepAliveClientMixin {
final List<NavigationRailDestination> _destinations =
<NavigationRailDestination>[
_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: <Widget>[
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<String, String> data = res as Map<String, String>;
List<dynamic> langsList = jsonDecode(data["langs"]!);
Map<String, String> langsMap = {for (var v in langsList) v[0]: v[1]};
List<String> keys = langsMap.keys.toList();
List<String> 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<SharedPreferences>()
.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<ServerModel>(
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<String> 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<String> keys = (data['all'] as List<String>).toList();
List<String> 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<Widget> 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<String> keys;
late final List<String> 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<String>(
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<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
)))),
);
}
}
//#endregion
//#region dialogs
void changeServer() async {
Map<String, dynamic> 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<String, dynamic> 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