From 7adc1900940d7db30ba0b96e0a7314aade17be13 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Mon, 11 Nov 2024 02:51:03 +0800 Subject: [PATCH] Added Hosts settings to support domain name mapping (#206) --- lib/network/components/hosts.dart | 2 + .../components/manager/hosts_manager.dart | 5 +- lib/network/handler.dart | 2 +- lib/ui/component/buttons.dart | 8 + lib/ui/component/toolbox/cert_hash.dart | 13 +- lib/ui/component/toolbox/regexp.dart | 9 +- lib/ui/component/toolbox/timestamp.dart | 20 +- lib/ui/desktop/toolbar/setting/filter.dart | 19 +- lib/ui/desktop/toolbar/setting/hosts.dart | 46 +- .../toolbar/setting/request_block.dart | 8 +- .../toolbar/setting/request_rewrite.dart | 11 +- lib/ui/desktop/toolbar/setting/script.dart | 8 +- lib/ui/mobile/menu/me.dart | 12 + lib/ui/mobile/mobile.dart | 8 +- lib/ui/mobile/setting/filter.dart | 12 +- lib/ui/mobile/setting/hosts.dart | 516 ++++++++++++++++++ lib/ui/mobile/setting/request_block.dart | 12 +- lib/ui/mobile/setting/request_rewrite.dart | 18 +- .../setting/rewrite/rewrite_update.dart | 2 +- lib/ui/mobile/setting/script.dart | 13 +- 20 files changed, 628 insertions(+), 116 deletions(-) create mode 100644 lib/ui/component/buttons.dart create mode 100644 lib/ui/mobile/setting/hosts.dart diff --git a/lib/network/components/hosts.dart b/lib/network/components/hosts.dart index eba68b9..3a1ff19 100644 --- a/lib/network/components/hosts.dart +++ b/lib/network/components/hosts.dart @@ -16,6 +16,7 @@ import 'package:proxypin/network/components/manager/hosts_manager.dart'; import 'package:proxypin/network/host_port.dart'; +import 'package:proxypin/network/util/logger.dart'; import 'interceptor.dart'; @@ -32,6 +33,7 @@ class Hosts extends Interceptor { var host = hostAndPort.host; var hostsItem = await hostsManager.then((it) => it.getHosts(host)); if (hostsItem != null) { + logger.d('Hosts: $host -> ${hostsItem.toAddress}'); return hostAndPort.copyWith(host: hostsItem.toAddress); } return hostAndPort; diff --git a/lib/network/components/manager/hosts_manager.dart b/lib/network/components/manager/hosts_manager.dart index 4fd981a..df8b2fb 100644 --- a/lib/network/components/manager/hosts_manager.dart +++ b/lib/network/components/manager/hosts_manager.dart @@ -174,9 +174,10 @@ class HostsItem { } //匹配url - bool match(String url) { + bool match(String domain) { + if (host != _hostReg?.pattern) _hostReg = null; _hostReg ??= RegExp(host.replaceAll("*", ".*")); - return _hostReg!.hasMatch(url); + return _hostReg!.hasMatch(domain); } factory HostsItem.fromJson(Map json) { diff --git a/lib/network/handler.dart b/lib/network/handler.dart index d754d1e..44ed40e 100644 --- a/lib/network/handler.dart +++ b/lib/network/handler.dart @@ -208,7 +208,7 @@ class HttpProxyChannelHandler extends ChannelHandler { HostAndPort remoteAddress = hostAndPort; for (var interceptor in interceptors) { - remoteAddress = await interceptor.preConnect(hostAndPort); + remoteAddress = await interceptor.preConnect(remoteAddress); } final proxyChannel = await connectRemote(channelContext, clientChannel, remoteAddress); diff --git a/lib/ui/component/buttons.dart b/lib/ui/component/buttons.dart new file mode 100644 index 0000000..4cae5a0 --- /dev/null +++ b/lib/ui/component/buttons.dart @@ -0,0 +1,8 @@ +import 'package:flutter/material.dart'; + +class Buttons { + static ButtonStyle get buttonStyle => ButtonStyle( + padding: WidgetStateProperty.all(EdgeInsets.symmetric(horizontal: 15, vertical: 8)), + shape: WidgetStateProperty.all( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)))); +} diff --git a/lib/ui/component/toolbox/cert_hash.dart b/lib/ui/component/toolbox/cert_hash.dart index dc5f815..3da2bba 100644 --- a/lib/ui/component/toolbox/cert_hash.dart +++ b/lib/ui/component/toolbox/cert_hash.dart @@ -22,6 +22,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_toastr/flutter_toastr.dart'; import 'package:proxypin/network/util/cert/x509.dart'; +import 'package:proxypin/ui/component/buttons.dart'; import 'package:proxypin/ui/component/text_field.dart'; ///证书哈希名称查看 @@ -66,13 +67,13 @@ class _CertHashPageState extends State { input.text = tryDerFormat(bytes) ?? String.fromCharCodes(bytes); getSubjectName(); }, - style: buttonStyle, + style: Buttons.buttonStyle, icon: const Icon(Icons.folder_open), label: Text("File")), const SizedBox(width: 15), ElevatedButton.icon( onPressed: () => input.clear(), - style: buttonStyle, + style: Buttons.buttonStyle, icon: const Icon(Icons.clear), label: const Text("Clear")), const SizedBox(width: 15), @@ -81,7 +82,7 @@ class _CertHashPageState extends State { getSubjectName(); FocusScope.of(context).unfocus(); }, - style: buttonStyle, + style: Buttons.buttonStyle, icon: const Icon(Icons.play_arrow_rounded), label: const Text("Run")), const SizedBox(width: 15), @@ -139,9 +140,5 @@ class _CertHashPageState extends State { } } - ButtonStyle get buttonStyle => ButtonStyle( - padding: WidgetStateProperty.all(EdgeInsets.symmetric(horizontal: 15, vertical: 8)), - textStyle: WidgetStateProperty.all(TextStyle(fontSize: 14)), - shape: WidgetStateProperty.all( - RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)))); + } diff --git a/lib/ui/component/toolbox/regexp.dart b/lib/ui/component/toolbox/regexp.dart index 9866d3e..f20228a 100644 --- a/lib/ui/component/toolbox/regexp.dart +++ b/lib/ui/component/toolbox/regexp.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_toastr/flutter_toastr.dart'; +import 'package:proxypin/ui/component/buttons.dart'; import 'package:proxypin/ui/component/text_field.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -54,12 +55,6 @@ class _RegExpPageState extends State { super.dispose(); } - ButtonStyle get buttonStyle => ButtonStyle( - padding: WidgetStateProperty.all(EdgeInsets.symmetric(horizontal: 15, vertical: 8)), - textStyle: WidgetStateProperty.all(TextStyle(fontSize: 14)), - shape: WidgetStateProperty.all( - RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)))); - @override Widget build(BuildContext context) { Color primaryColor = Theme.of(context).colorScheme.primary; @@ -143,7 +138,7 @@ class _RegExpPageState extends State { resultInput = input.text; }); }, - style: buttonStyle, + style: Buttons.buttonStyle, icon: const Icon(Icons.play_arrow_rounded), label: const Text('Run')), const SizedBox(width: 20), diff --git a/lib/ui/component/toolbox/timestamp.dart b/lib/ui/component/toolbox/timestamp.dart index edf1c34..760cbe6 100644 --- a/lib/ui/component/toolbox/timestamp.dart +++ b/lib/ui/component/toolbox/timestamp.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_toastr/flutter_toastr.dart'; +import 'package:proxypin/ui/component/buttons.dart'; import 'package:proxypin/utils/lang.dart'; import 'package:proxypin/utils/platform.dart'; @@ -32,12 +33,6 @@ class _TimestampPageState extends State { TextEditingController timestampOut = TextEditingController(); TextEditingController dateTimeOut = TextEditingController(); - ButtonStyle get buttonStyle => ButtonStyle( - padding: WidgetStateProperty.all(EdgeInsets.symmetric(horizontal: 15, vertical: 8)), - // textStyle: WidgetStateProperty.all(TextStyle(fontSize: 14)), - shape: WidgetStateProperty.all( - RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)))); - @override void initState() { super.initState(); @@ -47,7 +42,10 @@ class _TimestampPageState extends State { dateTime.text = DateTime.now().format(); //定时器 Timer.periodic(Duration(seconds: 1), (timer) { - if (!mounted) timer.cancel(); + if (!mounted) { + timer.cancel(); + return; + } nowTimestamp.text = (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString(); }); } @@ -144,8 +142,8 @@ class _TimestampPageState extends State { return SizedBox( height: 40, child: FilledButton.icon( - icon: Icon(Icons.play_arrow_rounded), - style: buttonStyle, + icon: Icon(Icons.play_arrow_rounded, size: 20), + style: Buttons.buttonStyle, label: Text(localizations.convert), onPressed: () => timestampConvert(timestamp.text))); } @@ -191,8 +189,8 @@ class _TimestampPageState extends State { return SizedBox( height: 40, child: FilledButton.icon( - icon: Icon(Icons.play_arrow_rounded), - style: buttonStyle, + icon: Icon(Icons.play_arrow_rounded, size: 20), + style: Buttons.buttonStyle, label: Text(localizations.convert), onPressed: () => timeConvert(dateTime.text))); } diff --git a/lib/ui/desktop/toolbar/setting/filter.dart b/lib/ui/desktop/toolbar/setting/filter.dart index bc163c8..9505895 100644 --- a/lib/ui/desktop/toolbar/setting/filter.dart +++ b/lib/ui/desktop/toolbar/setting/filter.dart @@ -141,22 +141,17 @@ class _DomainFilterState extends State { Text(localizations.enable), const SizedBox(width: 10), SwitchWidget( - scale: 0.8, + scale: 0.75, value: widget.hostList.enabled, onChanged: (value) { widget.hostList.enabled = value; changed = true; }), const Expanded(child: SizedBox()), - FilledButton.icon( - icon: const Icon(Icons.add, size: 14), - onPressed: add, - label: Text(localizations.add, style: const TextStyle(fontSize: 12))), - const SizedBox(width: 10), - FilledButton.icon( - icon: const Icon(Icons.input_rounded, size: 14), - onPressed: import, - label: Text(localizations.import, style: const TextStyle(fontSize: 12))), + TextButton.icon(icon: const Icon(Icons.add, size: 18), onPressed: add, label: Text(localizations.add)), + const SizedBox(width: 5), + TextButton.icon( + icon: const Icon(Icons.input_rounded, size: 18), onPressed: import, label: Text(localizations.import)), const SizedBox(width: 5), ]), DomainList(widget.hostList, onChange: () => changed = true) @@ -232,8 +227,9 @@ class DomainAddDialog extends StatelessWidget { onChanged: (val) => host = val) ]))), actions: [ + TextButton(child: Text(localizations.cancel), onPressed: () => Navigator.of(context).pop()), TextButton( - child: Text(localizations.add), + child: Text(localizations.save), onPressed: () { if (!(formKey.currentState as FormState).validate()) { return; @@ -249,7 +245,6 @@ class DomainAddDialog extends StatelessWidget { } Navigator.of(context).pop(host); }), - TextButton(child: Text(localizations.close), onPressed: () => Navigator.of(context).pop()) ]); } } diff --git a/lib/ui/desktop/toolbar/setting/hosts.dart b/lib/ui/desktop/toolbar/setting/hosts.dart index 811ee24..f4e6432 100644 --- a/lib/ui/desktop/toolbar/setting/hosts.dart +++ b/lib/ui/desktop/toolbar/setting/hosts.dart @@ -52,7 +52,9 @@ class _HostsDialogState extends State { saveConfig() { if (saving) return; + saving = true; Future.delayed(const Duration(milliseconds: 3000), () { + widget.hostsManager.flushConfig(); saving = false; }); } @@ -112,20 +114,20 @@ class _HostsDialogState extends State { saveConfig(); }), const Expanded(child: SizedBox()), - FilledButton.icon( - icon: const Icon(Icons.add, size: 14), + TextButton.icon( + icon: const Icon(Icons.add, size: 18), onPressed: showEdit, - label: Text(localizations.newBuilt, style: const TextStyle(fontSize: 12))), - const SizedBox(width: 10), - FilledButton.icon( - icon: const Icon(Icons.folder_outlined, size: 14), + label: Text(localizations.newBuilt)), + const SizedBox(width: 5), + TextButton.icon( + icon: const Icon(Icons.folder_outlined, size: 18), onPressed: newFolder, - label: Text(localizations.newFolder, style: const TextStyle(fontSize: 12))), - const SizedBox(width: 10), - FilledButton.icon( - icon: const Icon(Icons.input_rounded, size: 14), + label: Text(localizations.newFolder)), + const SizedBox(width: 5), + TextButton.icon( + icon: const Icon(Icons.input_rounded, size: 18), onPressed: import, - label: Text(localizations.import, style: const TextStyle(fontSize: 12))), + label: Text(localizations.import)), const SizedBox(width: 5), ]), const SizedBox(height: 8), @@ -236,14 +238,7 @@ class _HostsDialogState extends State { } newFolder() { - showDialog(context: context, builder: (BuildContext context) => FolderDialog(hostsManager: widget.hostsManager)) - .then((value) { - if (value != null) { - setState(() { - saveConfig(); - }); - } - }); + showEdit(isFolder: true); } enableStatus(bool enable) { @@ -271,7 +266,7 @@ class _HostsDialogState extends State { height: 35, enabled: selected.isNotEmpty, child: Text(localizations.deleteSelect), - onTap: () => removeRewrite(selected)), + onTap: () => removeHosts(selected)), ]); } @@ -316,10 +311,11 @@ class _HostsDialogState extends State { }); } - showEdit({HostsItem? item, HostsItem? parent}) { + showEdit({HostsItem? item, HostsItem? parent, bool? isFolder = false}) { + isFolder ??= item?.isFolder == true; showDialog( context: context, - builder: (BuildContext context) => item?.isFolder == true + builder: (BuildContext context) => isFolder == true ? FolderDialog(hostsManager: widget.hostsManager, folder: item) : HostsEditDialog(item: item, parent: parent)).then((value) { if (value != null) { @@ -331,7 +327,7 @@ class _HostsDialogState extends State { } //删除 - Future removeRewrite(Set items) async { + Future removeHosts(Set items) async { if (items.isEmpty) return; return showConfirmDialog(context, onConfirm: () async { await widget.hostsManager.removeHosts(items); @@ -433,6 +429,7 @@ class FolderDialog extends StatelessWidget { ]) ]), actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(localizations.cancel)), TextButton( onPressed: () { HostsItem item; @@ -447,7 +444,6 @@ class FolderDialog extends StatelessWidget { Navigator.pop(context, item); }, child: Text(localizations.save)), - TextButton(onPressed: () => Navigator.pop(context), child: Text(localizations.cancel)) ], ); } @@ -494,6 +490,7 @@ class _HostsEditDialogState extends State { return AlertDialog( contentPadding: const EdgeInsets.only(left: 20, right: 20, top: 10), actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(localizations.cancel)), TextButton( onPressed: () { if (!(formKey.currentState as FormState).validate()) { @@ -521,7 +518,6 @@ class _HostsEditDialogState extends State { Navigator.pop(context, hostItem); }, child: Text(localizations.save)), - TextButton(onPressed: () => Navigator.pop(context), child: Text(localizations.cancel)) ], content: SizedBox( width: 300, diff --git a/lib/ui/desktop/toolbar/setting/request_block.dart b/lib/ui/desktop/toolbar/setting/request_block.dart index 7e6596c..27aed9c 100644 --- a/lib/ui/desktop/toolbar/setting/request_block.dart +++ b/lib/ui/desktop/toolbar/setting/request_block.dart @@ -73,10 +73,8 @@ class _RequestBlockState extends State { changed = true; }), const Expanded(child: SizedBox()), - FilledButton.icon( - icon: const Icon(Icons.add, size: 14), - onPressed: showEdit, - label: Text(localizations.add, style: const TextStyle(fontSize: 12))), + TextButton.icon( + icon: const Icon(Icons.add, size: 18), onPressed: showEdit, label: Text(localizations.add)), const SizedBox(width: 5), ]), const SizedBox(height: 8), @@ -221,6 +219,7 @@ class RequestBlockAddDialog extends StatelessWidget { onChanged: (val) {}), ]))), actions: [ + TextButton(child: Text(localizations.close), onPressed: () => Navigator.of(context).pop()), TextButton( child: Text(localizations.save), onPressed: () { @@ -238,7 +237,6 @@ class RequestBlockAddDialog extends StatelessWidget { } Navigator.of(context).pop(item); }), - TextButton(child: Text(localizations.close), onPressed: () => Navigator.of(context).pop()) ]); } } diff --git a/lib/ui/desktop/toolbar/setting/request_rewrite.dart b/lib/ui/desktop/toolbar/setting/request_rewrite.dart index 52b373a..5bb7bf4 100644 --- a/lib/ui/desktop/toolbar/setting/request_rewrite.dart +++ b/lib/ui/desktop/toolbar/setting/request_rewrite.dart @@ -124,16 +124,15 @@ class RequestRewriteState extends State { onPressed: refresh, icon: const Icon(Icons.refresh, color: Colors.blue), tooltip: localizations.refresh), - const SizedBox(width: 30), - FilledButton.icon( + const SizedBox(width: 10), + TextButton.icon( icon: const Icon(Icons.add, size: 18), - label: Text(localizations.add, style: const TextStyle(fontSize: 12)), + label: Text(localizations.add), onPressed: add, ), - const SizedBox(width: 20), - FilledButton.icon( + const SizedBox(width: 5), + TextButton.icon( icon: const Icon(Icons.input_rounded, size: 18), - style: ElevatedButton.styleFrom(padding: const EdgeInsets.only(left: 20, right: 20)), onPressed: import, label: Text(localizations.import), ) diff --git a/lib/ui/desktop/toolbar/setting/script.dart b/lib/ui/desktop/toolbar/setting/script.dart index 9cbe273..ca8f9be 100644 --- a/lib/ui/desktop/toolbar/setting/script.dart +++ b/lib/ui/desktop/toolbar/setting/script.dart @@ -110,7 +110,7 @@ class _ScriptWidgetState extends State { subtitle: Text(localizations.scriptUseDescribe), trailing: SwitchWidget( value: data.enabled, - scale: 0.9, + scale: 0.8, onChanged: (value) { data.enabled = value; _refreshScript(); @@ -120,18 +120,18 @@ class _ScriptWidgetState extends State { mainAxisAlignment: MainAxisAlignment.end, children: [ const SizedBox(width: 10), - FilledButton.icon( + TextButton.icon( icon: const Icon(Icons.add, size: 18), onPressed: scriptAdd, label: Text(localizations.add)), const SizedBox(width: 10), - FilledButton.icon( + TextButton.icon( icon: const Icon(Icons.input_rounded, size: 18), onPressed: import, label: Text(localizations.import), ), const SizedBox(width: 10), - FilledButton.icon( + TextButton.icon( icon: const Icon(Icons.terminal, size: 18), onPressed: consoleLog, label: Text(localizations.logger), diff --git a/lib/ui/mobile/menu/me.dart b/lib/ui/mobile/menu/me.dart index bfa9cfd..1a70a29 100644 --- a/lib/ui/mobile/menu/me.dart +++ b/lib/ui/mobile/menu/me.dart @@ -17,12 +17,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:proxypin/network/bin/server.dart'; +import 'package:proxypin/network/components/manager/hosts_manager.dart'; import 'package:proxypin/network/components/manager/request_block_manager.dart'; import 'package:proxypin/network/components/manager/request_rewrite_manager.dart'; import 'package:proxypin/storage/histories.dart'; import 'package:proxypin/ui/component/utils.dart'; import 'package:proxypin/ui/configuration.dart'; import 'package:proxypin/ui/mobile/menu/drawer.dart'; +import 'package:proxypin/ui/mobile/setting/hosts.dart'; import 'package:proxypin/ui/mobile/setting/preference.dart'; import 'package:proxypin/ui/mobile/mobile.dart'; import 'package:proxypin/ui/mobile/request/favorite.dart'; @@ -92,6 +94,16 @@ class _MePageState extends State { leading: Icon(Icons.filter_alt_outlined, color: color), trailing: const Icon(Icons.arrow_forward_ios, size: 16), onTap: () => navigator(context, FilterMenu(proxyServer: proxyServer))), + ListTile( + title: Text(localizations.hosts), + leading: Icon(Icons.domain, color: color), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: () async { + var hostsManager = await HostsManager.instance; + if (context.mounted) { + navigator(context, HostsPage(hostsManager: hostsManager)); + } + }), ListTile( title: Text(localizations.requestBlock), leading: Icon(Icons.block_flipped, color: color), diff --git a/lib/ui/mobile/mobile.dart b/lib/ui/mobile/mobile.dart index 3af6b16..af18f9c 100644 --- a/lib/ui/mobile/mobile.dart +++ b/lib/ui/mobile/mobile.dart @@ -259,8 +259,8 @@ class MobileHomeState extends State implements EventListener, Li String content = isCN ? '提示:默认不会开启HTTPS抓包,请安装证书后再开启HTTPS抓包。\n\n' - '1. 请求重写升级UI优化, 请求修改增加匹配数据查看;\n' - '2. 请求弹出菜单UI优化, 支持请求高亮;\n' + '1. 新增Hosts设置, 支持域名映射;\n' + '2. 工具箱新增时间戳转换;\n' '3. 脚本内置File Api, 支持文件读取、写入等操作, 详细查看wiki文档;\n' "4. 脚本内置MD5方法, md5('xxx');\n" '5. 支持内存自动清理设置, 到内存限制自动清理请求;\n' @@ -269,8 +269,8 @@ class MobileHomeState extends State implements EventListener, Li '8. 修复暗黑模式icon展示不清晰;\n' : 'Tips:By default, HTTPS packet capture will not be enabled. Please install the certificate before enabling HTTPS packet capture。\n\n' 'Click HTTPS Capture packets(Lock icon),Choose to install the root certificate and follow the prompts to proceed。\n\n' - '1. Request to rewrite and upgrade UI optimization, request to modify and add matching data viewing;\n' - '2. Request pop-up menu UI optimization, support request highlighting;\n' + '1. Added Hosts settings to support domain name mapping;\n' + '2. Toolbox adds timestamp conversion;\n' '3. The script has built-in File Api, which supports file reading, writing and other operations. For details, please refer to the wiki document;\n' "4. The script has built-in MD5 method, md5('xxx');\n" '5. Support memory automatic cleanup settings, memory limit automatic cleanup requests;\n' diff --git a/lib/ui/mobile/setting/filter.dart b/lib/ui/mobile/setting/filter.dart index d983702..806f327 100644 --- a/lib/ui/mobile/setting/filter.dart +++ b/lib/ui/mobile/setting/filter.dart @@ -124,10 +124,10 @@ class _DomainFilterState extends State { }); }), Row(mainAxisAlignment: MainAxisAlignment.end, children: [ - FilledButton.icon(icon: const Icon(Icons.add), onPressed: add, label: Text(localizations.add)), + TextButton.icon(icon: const Icon(Icons.add, size: 20), onPressed: add, label: Text(localizations.add)), const SizedBox(width: 10), - FilledButton.icon( - icon: const Icon(Icons.input_rounded), onPressed: import, label: Text(localizations.import)), + TextButton.icon( + icon: const Icon(Icons.input_rounded, size: 20), onPressed: import, label: Text(localizations.import)), const SizedBox(width: 5), ]), Expanded(child: DomainList(widget.hostList, onChange: () => changed = true)) @@ -200,7 +200,8 @@ class DomainAddDialog extends StatelessWidget { onChanged: (val) => host = val) ]))), actions: [ - FilledButton( + TextButton(child: Text(localizations.cancel), onPressed: () => Navigator.of(context).pop()), + TextButton( child: Text(localizations.save), onPressed: () { if (!(formKey.currentState as FormState).validate()) { @@ -217,7 +218,6 @@ class DomainAddDialog extends StatelessWidget { } Navigator.of(context).pop(host); }), - ElevatedButton(child: Text(localizations.close), onPressed: () => Navigator.of(context).pop()) ]); } } @@ -337,7 +337,7 @@ class _DomainListState extends State { color: selected.contains(index) ? primaryColor.withOpacity(0.8) : index.isEven - ? Colors.grey.withOpacity(0.15) + ? Colors.grey.withOpacity(0.1) : null, height: 38, padding: const EdgeInsets.symmetric(vertical: 3), diff --git a/lib/ui/mobile/setting/hosts.dart b/lib/ui/mobile/setting/hosts.dart new file mode 100644 index 0000000..62fa8d5 --- /dev/null +++ b/lib/ui/mobile/setting/hosts.dart @@ -0,0 +1,516 @@ +/* + * Copyright 2023 Hongen Wang + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:convert'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_toastr/flutter_toastr.dart'; +import 'package:proxypin/network/components/manager/hosts_manager.dart'; +import 'package:proxypin/network/util/logger.dart'; +import 'package:proxypin/ui/component/utils.dart'; +import 'package:proxypin/ui/component/widgets.dart'; + +/// Hosts page +/// @author wanghongen +class HostsPage extends StatefulWidget { + final HostsManager hostsManager; + + const HostsPage({super.key, required this.hostsManager}); + + @override + State createState() => _HostsPageState(); +} + +class _HostsPageState extends State { + late HostsManager hostsManager = widget.hostsManager; + Set selected = {}; + Set offstage = {}; + + bool multiple = false; + + bool saving = false; + + AppLocalizations get localizations => AppLocalizations.of(context)!; + + @override + void initState() { + super.initState(); + } + + saveConfig() { + if (saving) return; + saving = true; + Future.delayed(const Duration(milliseconds: 3000), () { + widget.hostsManager.flushConfig(); + saving = false; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(centerTitle: true, title: Text('Hosts', style: const TextStyle(fontSize: 16))), + persistentFooterButtons: [multiple ? globalMenu() : const SizedBox()], + body: Padding( + padding: const EdgeInsets.all(8), + child: Column( + children: [ + Row(children: [ + Container(width: 15), + Text(localizations.enable), + const SizedBox(width: 10), + SwitchWidget( + scale: 0.8, + value: widget.hostsManager.enabled, + onChanged: (value) { + widget.hostsManager.enabled = value; + saveConfig(); + }), + ]), + Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + TextButton.icon( + icon: const Icon(Icons.add, size: 18), onPressed: showEdit, label: Text(localizations.newBuilt)), + TextButton.icon( + icon: const Icon(Icons.folder_outlined, size: 18), + onPressed: newFolder, + label: Text(localizations.newFolder)), + TextButton.icon( + icon: const Icon(Icons.input_rounded, size: 18), + onPressed: import, + label: Text(localizations.import)), + SizedBox(width: 3), + ]), + const SizedBox(height: 8), + Expanded( + child: Column(children: [ + const SizedBox(height: 5), + Row(children: [ + Container(width: 15), + SizedBox(width: 50, child: Text(localizations.enable, style: const TextStyle(fontSize: 14))), + Container(width: 15), + Expanded(child: Text(localizations.domain, style: TextStyle(fontSize: 14))), + Container(width: 15), + Expanded(child: Text(localizations.toAddress, style: const TextStyle(fontSize: 14))), + ]), + const Divider(thickness: 0.5), + Expanded( + child: ListView.builder( + shrinkWrap: true, + itemCount: widget.hostsManager.list.length, + padding: const EdgeInsets.only(right: 10), + itemBuilder: (_, index) => row(widget.hostsManager.list[index], index.isEven))) + ])), + ], + ))); + } + + Widget row(HostsItem item, bool isEven, {EdgeInsetsGeometry? padding}) { + var primaryColor = Theme.of(context).colorScheme.primary; + + return Column(children: [ + GestureDetector( + onLongPressStart: (details) => showMenus(details, item), + onTap: () { + if (multiple) { + setState(() { + selected.contains(item) ? selected.remove(item) : selected.add(item); + }); + return; + } + + if (item.isFolder) { + setState(() { + offstage.contains(item.id) ? offstage.remove(item.id) : offstage.add(item.id); + }); + return; + } + showEdit(item: item); + }, + child: Container( + color: selected.contains(item) + ? primaryColor.withOpacity(0.6) + : isEven + ? Colors.grey.withOpacity(0.1) + : null, + height: 42, + padding: padding ?? const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + SwitchWidget( + scale: 0.6, + value: item.enabled, + onChanged: (val) { + setState(() { + item.enabled = val; + saveConfig(); + }); + }), + Container(width: 15), + Expanded( + child: IconText( + icon: item.isFolder + ? Icon(offstage.contains(item.id) ? Icons.folder : Icons.folder_outlined, size: 18) + : null, + text: item.host, + textStyle: const TextStyle(fontSize: 14))), + Container(width: 15), + Expanded(child: Text(item.toAddress ?? '', style: const TextStyle(fontSize: 14))) + ], + ))), + if (item.isFolder) + Offstage( + offstage: offstage.contains(item.id), + child: Column( + children: widget.hostsManager + .getFolderList(item.id) + .map((e) => row(e, !isEven, padding: EdgeInsets.only(left: 60))) + .toList())) + ]); + } + + newFolder() { + showEdit(isFolder: true); + } + + showEdit({HostsItem? item, HostsItem? parent, bool? isFolder = false}) { + isFolder ??= item?.isFolder == true; + showDialog( + context: context, + builder: (BuildContext context) => isFolder == true + ? FolderDialog(hostsManager: widget.hostsManager, folder: item) + : HostsEditDialog(item: item, parent: parent)).then((value) { + if (value != null) { + setState(() { + saveConfig(); + }); + } + }); + } + + globalMenu() { + return Stack(children: [ + Container( + height: 50, + width: double.infinity, + margin: const EdgeInsets.only(top: 10), + decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2)))), + Positioned( + top: 0, + left: 0, + right: 0, + child: Center( + child: TextButton( + onPressed: () {}, + child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + TextButton.icon( + onPressed: () { + export(selected); + setState(() { + selected.clear(); + multiple = false; + }); + }, + icon: const Icon(Icons.share, size: 18), + label: Text(localizations.export, style: const TextStyle(fontSize: 14))), + TextButton.icon( + onPressed: () => removeHosts(selected), + icon: const Icon(Icons.delete, size: 18), + label: Text(localizations.delete, style: const TextStyle(fontSize: 14))), + TextButton.icon( + onPressed: () { + setState(() { + multiple = false; + selected.clear(); + }); + }, + icon: const Icon(Icons.cancel, size: 18), + label: Text(localizations.cancel, style: const TextStyle(fontSize: 14))), + ])))) + ]); + } + + //点击菜单 + showMenus(LongPressStartDetails details, HostsItem item) { + //长按反馈 + HapticFeedback.lightImpact(); + + setState(() { + selected.add(item); + }); + + showContextMenu(context, details.globalPosition, items: [ + if (item.isFolder) + PopupMenuItem(height: 35, child: Text(localizations.newBuilt), onTap: () => showEdit(parent: item)), + PopupMenuItem(height: 35, child: Text(localizations.multiple), onTap: () => setState(() => multiple = true)), + PopupMenuItem(height: 35, child: Text(localizations.edit), onTap: () => showEdit(item: item)), + PopupMenuItem(height: 35, onTap: () => export([item]), child: Text(localizations.export)), + PopupMenuItem( + height: 35, + child: item.enabled ? Text(localizations.disabled) : Text(localizations.enable), + onTap: () { + setState(() { + item.enabled = !item.enabled; + saveConfig(); + }); + }), + const PopupMenuDivider(), + PopupMenuItem( + height: 35, + child: Text(localizations.delete), + onTap: () async { + setState(() { + widget.hostsManager.removeHosts([item]); + }); + }) + ]).then((value) { + setState(() { + selected.remove(item); + }); + }); + } + + //删除 + Future removeHosts(Set items) async { + if (items.isEmpty) return; + return showConfirmDialog(context, onConfirm: () async { + await widget.hostsManager.removeHosts(items); + setState(() { + multiple = false; + items.clear(); + }); + if (mounted) FlutterToastr.show(localizations.deleteSuccess, context); + }); + } + + //导入 + import() async { + final FilePickerResult? result = await FilePicker.platform.pickFiles(type: FileType.any); + var file = result?.files.single; + if (file == null) { + return; + } + + try { + List json = jsonDecode(await file.xFile.readAsString()); + Map idMap = {}; + + for (var item in json) { + //生成新的id 保存映射关系 + String newId = HostsItem.generateId(); + idMap[item['id']] = newId; + item['id'] = newId; + var hostsItem = HostsItem.fromJson(item); + + if (hostsItem.parent != null) { + hostsItem.parent = idMap[hostsItem.parent!]; + } + + widget.hostsManager.addHosts(hostsItem); + } + + saveConfig(); + if (mounted) { + FlutterToastr.show(localizations.importSuccess, context); + } + setState(() {}); + } catch (e, t) { + logger.e('导入失败 $file', error: e, stackTrace: t); + if (mounted) { + FlutterToastr.show("${localizations.importFailed} $e", context); + } + } + } + + //导出 + export(Iterable items) async { + if (items.isEmpty) return; + + String fileName = 'hosts.json'; + var list = []; + for (var item in items) { + var json = item.toJson(); + list.add(json); + } + + var path = await FilePicker.platform.saveFile(fileName: fileName, bytes: utf8.encode(jsonEncode(list))); + if (path == null) { + return; + } + if (mounted) FlutterToastr.show(localizations.exportSuccess, context); + } +} + +class FolderDialog extends StatelessWidget { + final HostsManager hostsManager; + final HostsItem? folder; + + const FolderDialog({super.key, required this.hostsManager, this.folder}); + + @override + Widget build(BuildContext context) { + AppLocalizations localizations = AppLocalizations.of(context)!; + bool enabled = folder?.enabled ?? true; + String name = folder?.host ?? ''; + + return AlertDialog( + title: Text(localizations.newFolder, style: const TextStyle(fontSize: 16)), + content: Column(mainAxisSize: MainAxisSize.min, children: [ + Row(children: [ + SizedBox(width: 55, child: Text(localizations.enable)), + SwitchWidget(scale: 0.8, value: enabled, onChanged: (value) => enabled = value) + ]), + SizedBox(height: 10), + Row(children: [ + SizedBox(width: 55, child: Text(localizations.name)), + Expanded( + child: TextFormField( + minLines: 1, + maxLines: 3, + initialValue: name, + onChanged: (val) => name = val, + decoration: InputDecoration(border: OutlineInputBorder()))) + ]) + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(localizations.cancel)), + TextButton( + onPressed: () { + HostsItem item; + if (folder == null) { + item = HostsItem(isFolder: true, host: name, enabled: enabled); + hostsManager.addHosts(item); + } else { + folder!.enabled = enabled; + folder!.host = name; + item = folder!; + } + Navigator.pop(context, item); + }, + child: Text(localizations.save)), + ], + ); + } +} + +class HostsEditDialog extends StatefulWidget { + final HostsItem? item; + final HostsItem? parent; + + const HostsEditDialog({super.key, this.item, this.parent}); + + @override + State createState() => _HostsEditDialogState(); +} + +class _HostsEditDialogState extends State { + GlobalKey formKey = GlobalKey(); + + bool enabled = true; + TextEditingController hostController = TextEditingController(); + TextEditingController toAddressController = TextEditingController(); + + AppLocalizations get localizations => AppLocalizations.of(context)!; + + @override + void initState() { + super.initState(); + if (widget.item != null) { + enabled = widget.item!.enabled; + hostController.text = widget.item!.host; + toAddressController.text = widget.item!.toAddress ?? ''; + } + } + + @override + void dispose() { + hostController.dispose(); + toAddressController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + contentPadding: const EdgeInsets.only(left: 20, right: 20, top: 10), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(localizations.cancel)), + TextButton( + onPressed: () { + if (!(formKey.currentState as FormState).validate()) { + FlutterToastr.show( + "${localizations.domain} ${localizations.toAddress} ${localizations.cannotBeEmpty}", context, + position: FlutterToastr.center); + return; + } + + HostsItem? hostItem; + if (widget.item == null) { + hostItem = HostsItem( + enabled: enabled, + parent: widget.parent?.id, + host: hostController.text, + toAddress: toAddressController.text); + HostsManager.instance.then((it) => it.addHosts(hostItem!)); + } else { + widget.item!.enabled = enabled; + widget.item!.host = hostController.text; + widget.item!.toAddress = toAddressController.text; + hostItem = widget.item; + } + + Navigator.pop(context, hostItem); + }, + child: Text(localizations.save)), + ], + content: Form( + key: formKey, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Row(children: [ + SizedBox(width: 80, child: Text(localizations.enable)), + Expanded(child: SwitchWidget(scale: 0.8, value: enabled, onChanged: (value) => enabled = value)), + ]), + const SizedBox(height: 8), + Row(children: [ + SizedBox(width: 80, child: Text(localizations.domain)), + Expanded( + child: TextFormField( + controller: hostController, + validator: (val) => val == null || val.trim().isEmpty ? localizations.cannotBeEmpty : null, + decoration: const InputDecoration( + hintText: '*.example.com', + hintStyle: TextStyle(color: Colors.grey), + errorStyle: TextStyle(height: 0, fontSize: 0), + border: OutlineInputBorder()))), + ]), + const SizedBox(height: 10), + Row(children: [ + SizedBox(width: 80, child: Text(localizations.toAddress)), + Expanded( + child: TextFormField( + controller: toAddressController, + validator: (val) => val == null || val.trim().isEmpty ? localizations.cannotBeEmpty : null, + decoration: const InputDecoration( + hintText: '202.108.22.5', + errorStyle: TextStyle(height: 0, fontSize: 0), + hintStyle: TextStyle(color: Colors.grey), + border: OutlineInputBorder()))), + ]), + ]))); + } +} diff --git a/lib/ui/mobile/setting/request_block.dart b/lib/ui/mobile/setting/request_block.dart index 18945cf..d48b7d1 100644 --- a/lib/ui/mobile/setting/request_block.dart +++ b/lib/ui/mobile/setting/request_block.dart @@ -36,10 +36,8 @@ class _RequestBlockState extends State { widget.requestBlockManager.flushConfig(); }), const Expanded(child: SizedBox()), - FilledButton.icon( - icon: const Icon(Icons.add, size: 14), - onPressed: showEdit, - label: Text(localizations.add, style: const TextStyle(fontSize: 14))), + TextButton.icon( + icon: const Icon(Icons.add, size: 20), onPressed: showEdit, label: Text(localizations.add)), const SizedBox(width: 5), ]), const SizedBox(height: 10), @@ -76,7 +74,7 @@ class _RequestBlockState extends State { onLongPress: () => showMenus(index), onTap: () => showEdit(index), child: Container( - color: index.isEven ? Colors.grey.withOpacity(0.15) : null, + color: index.isEven ? Colors.grey.withOpacity(0.1) : null, height: 38, padding: const EdgeInsets.symmetric(vertical: 3), child: Row( @@ -206,7 +204,8 @@ class RequestBlockAddDialog extends StatelessWidget { onChanged: (val) {}), ]))), actions: [ - FilledButton( + TextButton(child: Text(localizations.cancel), onPressed: () => Navigator.of(context).pop()), + TextButton( child: Text(localizations.save), onPressed: () { if (!(formKey.currentState as FormState).validate()) { @@ -224,7 +223,6 @@ class RequestBlockAddDialog extends StatelessWidget { requestBlockManager.flushConfig(); Navigator.of(context).pop(item); }), - ElevatedButton(child: Text(localizations.close), onPressed: () => Navigator.of(context).pop()) ]); } } diff --git a/lib/ui/mobile/setting/request_rewrite.dart b/lib/ui/mobile/setting/request_rewrite.dart index b99bc31..5709e6b 100644 --- a/lib/ui/mobile/setting/request_rewrite.dart +++ b/lib/ui/mobile/setting/request_rewrite.dart @@ -81,15 +81,13 @@ class _MobileRequestRewriteState extends State { ], ), Row(mainAxisAlignment: MainAxisAlignment.end, children: [ - FilledButton.icon( - icon: const Icon(Icons.add, size: 18), onPressed: add, label: Text(localizations.add)), - const SizedBox(width: 10), - FilledButton.icon( - icon: const Icon(Icons.input_rounded, size: 18), - style: ElevatedButton.styleFrom(padding: const EdgeInsets.only(left: 20, right: 20)), - onPressed: import, - label: Text(localizations.import), - ), + TextButton.icon( + icon: const Icon(Icons.add, size: 20), onPressed: add, label: Text(localizations.add)), + const SizedBox(width: 5), + TextButton.icon( + icon: const Icon(Icons.input_rounded, size: 20), + onPressed: import, + label: Text(localizations.import)), ]), const SizedBox(height: 10), Expanded(child: RequestRuleList(widget.requestRewrites)), @@ -263,7 +261,7 @@ class _RequestRuleListState extends State { color: selected.contains(index) ? primaryColor.withOpacity(0.8) : index.isEven - ? Colors.grey.withOpacity(0.15) + ? Colors.grey.withOpacity(0.1) : null, height: 45, padding: const EdgeInsets.all(5), diff --git a/lib/ui/mobile/setting/rewrite/rewrite_update.dart b/lib/ui/mobile/setting/rewrite/rewrite_update.dart index bb9317b..0f81d77 100644 --- a/lib/ui/mobile/setting/rewrite/rewrite_update.dart +++ b/lib/ui/mobile/setting/rewrite/rewrite_update.dart @@ -395,7 +395,7 @@ class _UpdateListState extends State { color: selected == index ? primaryColor : index.isEven - ? Colors.grey.withOpacity(0.15) + ? Colors.grey.withOpacity(0.1) : null, constraints: const BoxConstraints(minHeight: 38, maxHeight: 45), padding: const EdgeInsets.all(5), diff --git a/lib/ui/mobile/setting/script.dart b/lib/ui/mobile/setting/script.dart index 3bb4d39..f931dea 100644 --- a/lib/ui/mobile/setting/script.dart +++ b/lib/ui/mobile/setting/script.dart @@ -88,19 +88,18 @@ class _MobileScriptState extends State { Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - const SizedBox(width: 10), - FilledButton.icon( + TextButton.icon( icon: const Icon(Icons.add, size: 18), onPressed: scriptEdit, label: Text(localizations.add)), - const SizedBox(width: 10), - FilledButton.icon( + const SizedBox(width: 5), + TextButton.icon( icon: const Icon(Icons.input_rounded, size: 18), onPressed: import, label: Text(localizations.import), ), - const SizedBox(width: 10), - FilledButton.icon( + const SizedBox(width: 5), + TextButton.icon( icon: const Icon(Icons.terminal, size: 18), onPressed: consoleLog, label: Text(localizations.logger), @@ -610,7 +609,7 @@ class _ScriptListState extends State { color: selected.contains(index) ? primaryColor.withOpacity(0.8) : index.isEven - ? Colors.grey.withOpacity(0.15) + ? Colors.grey.withOpacity(0.1) : null, height: 45, padding: const EdgeInsets.all(5),