diff --git a/README.md b/README.md index 2673dda..a55e0c9 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ 国内下载地址:https://gitee.com/wanghongenpin/network-proxy-flutter/releases +ios下载地址(Safari浏览器打开): https://testflight.apple.com/join/gURGH6B4 + > ios个人开发者账号用到VPN没法上架AppStore, 后面可能会上架美版AppStore。 - [ ] 接下来会持续完善功能和体验,请求重放编辑、模拟慢请求, UI优化。 diff --git a/lib/main.dart b/lib/main.dart index 3478118..58dcc39 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -92,6 +92,30 @@ class _DesktopHomePagePageState extends State implements EventL void initState() { super.initState(); proxyServer = ProxyServer(listener: this); + proxyServer.initializedListener(() { + if (!proxyServer.guide) { + return; + } + //首次引导 + showDialog( + context: context, + barrierDismissible: false, + builder: (_) { + return AlertDialog( + actions: [ + TextButton( + onPressed: () { + proxyServer.guide = false; + proxyServer.flushConfig(); + Navigator.pop(context); + }, + child: const Text('关闭')) + ], + title: const Text('提示', style: TextStyle(fontSize: 18)), + content: const Text('默认不会开启HTTPS抓包,请安装证书后再开启HTTPS抓包。\n' + '点击的HTTPS抓包(加锁图标),选择安装根证书,按照提示操作即可。')); + }); + }); } @override diff --git a/lib/network/bin/server.dart b/lib/network/bin/server.dart index 9c43c46..2f4df9a 100644 --- a/lib/network/bin/server.dart +++ b/lib/network/bin/server.dart @@ -19,11 +19,19 @@ Future main() async { /// 代理服务器 class ProxyServer { + //是否初始化 bool init = false; int port = 9099; bool _enableSsl = false; + bool enableDesktop = true; + //是否引导 + bool guide = false; + + //是否启动 + bool get isRunning => server?.isRunning ?? false; + Server? server; EventListener? listener; RequestRewrites requestRewrites = RequestRewrites(); @@ -32,8 +40,6 @@ class ProxyServer { ProxyServer({this.listener}); - bool get enableSsl => _enableSsl; - //初始化 Future initializedListener(Function action) async { _initializedListeners.add(action); @@ -58,7 +64,9 @@ class ProxyServer { return File("${home.path}${separator}config.cnf"); } - /// 是否启用ssl + ///是否启用https抓包 + bool get enableSsl => _enableSsl; + set enableSsl(bool enableSsl) { _enableSsl = enableSsl; server?.enableSsl = enableSsl; @@ -136,13 +144,16 @@ class ProxyServer { var file = await configFile(); var exits = await file.exists(); if (!exits) { + guide = true; return; } + Map config = jsonDecode(await file.readAsString()); logger.i('加载配置文件 [$file]'); port = config['port'] ?? port; enableSsl = config['enableSsl'] == true; enableDesktop = config['enableDesktop'] ?? true; + guide = config['guide'] ?? false; HostFilter.whitelist.load(config['whitelist']); HostFilter.blacklist.load(config['blacklist']); @@ -179,6 +190,7 @@ class ProxyServer { Map toJson() { return { + 'guide': guide, 'port': port, 'enableSsl': enableSsl, 'enableDesktop': enableDesktop, diff --git a/lib/network/channel.dart b/lib/network/channel.dart index 3fccd9e..8e7e719 100644 --- a/lib/network/channel.dart +++ b/lib/network/channel.dart @@ -41,6 +41,9 @@ class Channel { final InternetAddress remoteAddress; final int remotePort; + //是否写入中 + bool isWriting = false; + Channel(this._socket) : _id = DateTime.now().millisecondsSinceEpoch + Random().nextInt(999999), remoteAddress = _socket.remoteAddress, @@ -58,10 +61,14 @@ class Channel { logger.w("channel is closed $obj"); return; } - - var data = pipeline._encoder.encode(obj); - _socket.add(data); - await _socket.flush(); + isWriting = true; + try { + var data = pipeline._encoder.encode(obj); + _socket.add(data); + await _socket.flush(); + } finally { + isWriting = false; + } } Future writeAndClose(Object obj) async { @@ -69,10 +76,16 @@ class Channel { close(); } - void close() { + void close() async { if (isClosed) { return; } + + //写入中,延迟关闭 + int retry = 0; + while (isWriting && retry++ < 10) { + await Future.delayed(const Duration(milliseconds: 150)); + } _socket.destroy(); isOpen = false; } diff --git a/lib/network/util/crts.dart b/lib/network/util/crts.dart index c2a2563..8aec2af 100644 --- a/lib/network/util/crts.dart +++ b/lib/network/util/crts.dart @@ -38,6 +38,11 @@ class CertificateManager { static X509CertificateData get caCert => _caCert; + /// 清除缓存 + static void cleanCache() { + _certificateMap.clear(); + } + /// 获取域名自签名证书 static Future getCertificateContext(String host) async { var cer = _certificateMap[host]; diff --git a/lib/ui/component/guide.dart b/lib/ui/component/guide.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/ui/component/transition.dart b/lib/ui/component/transition.dart index e71d3e4..eff6305 100644 --- a/lib/ui/component/transition.dart +++ b/lib/ui/component/transition.dart @@ -6,13 +6,15 @@ class ColorTransition extends StatefulWidget { final Color end; final Duration duration; final Widget child; + final bool startAnimation; const ColorTransition( {super.key, - required this.begin, - this.end = Colors.transparent, - this.duration = const Duration(milliseconds: 500), - required this.child}); + required this.begin, + this.end = Colors.transparent, + this.duration = const Duration(milliseconds: 1000), + required this.child, + this.startAnimation = true}); @override State createState() { @@ -42,10 +44,14 @@ class ColorTransitionState extends State with SingleTickerProvi //颜色动画变化 _animation = ColorTween(begin: widget.begin, end: widget.end).animate(_animationController); - //添加到事件队列 - Future.delayed(const Duration(milliseconds: 150), () { - _animationController.forward(); - }); + if (widget.startAnimation) { + //延迟150毫秒执行动画 + Future.delayed(const Duration(milliseconds: 150), () { + _animationController.forward(); + }); + } else { + _animationController.value = _animationController.upperBound; + } } show() { diff --git a/lib/ui/desktop/left/domain.dart b/lib/ui/desktop/left/domain.dart index a391d45..7efd5e4 100644 --- a/lib/ui/desktop/left/domain.dart +++ b/lib/ui/desktop/left/domain.dart @@ -10,6 +10,7 @@ import 'package:network_proxy/network/util/host_filter.dart'; import 'package:network_proxy/ui/component/transition.dart'; import 'package:network_proxy/ui/desktop/left/path.dart'; import 'package:network_proxy/ui/content/panel.dart'; +import 'package:network_proxy/ui/desktop/left/search.dart'; ///左侧域名 class DomainWidget extends StatefulWidget { @@ -27,10 +28,51 @@ class DomainWidget extends StatefulWidget { class DomainWidgetState extends State { LinkedHashMap containerMap = LinkedHashMap(); + //搜索的文本 + String? searchText; + bool changing = false; //是否存在刷新任务 + + changeState() { + if (!changing) { + changing = true; + Future.delayed(const Duration(milliseconds: 1500), () { + setState(() { + changing = false; + }); + }); + } + } + @override Widget build(BuildContext context) { var list = containerMap.values; - return SingleChildScrollView(child: Column(children: list.toList())); + //根究搜素文本过滤 + if (searchText?.trim().isNotEmpty == true) { + list = searchFilter(searchText!); + } + + return Scaffold( + body: SingleChildScrollView(child: Column(children: list.toList())), + bottomNavigationBar: Search(onSearch: (val) { + if (val == searchText) { + return; + } + setState(() { + searchText = val.toLowerCase(); + }); + })); + } + + ///搜索过滤 + List searchFilter(String text) { + var result = []; + containerMap.forEach((key, headerBody) { + var body = headerBody.filter(text); + if (body.isNotEmpty) { + result.add(headerBody.copy(body: body, selected: true)); + } + }); + return result; } ///添加请求 @@ -41,6 +83,11 @@ class DomainWidgetState extends State { var listURI = PathRow(request, widget.panel, proxyServer: widget.proxyServer); if (headerBody != null) { headerBody.addBody(channel.id, listURI); + + //搜索状态,刷新数据 + if (searchText?.isNotEmpty == true) { + changeState(); + } return; } @@ -75,12 +122,19 @@ class DomainWidgetState extends State { ///标题和内容布局 标题是域名 内容是域名下请求 class HeaderBody extends StatefulWidget { + //请求ID和请求的映射 final Map channelIdPathMap = HashMap(); final HostAndPort header; final ProxyServer proxyServer; + + //请求列表 final Queue _body = Queue(); + + //是否选中 final bool selected; + + //移除回调 final Function()? onRemove; HeaderBody(this.header, {this.selected = false, this.onRemove, required this.proxyServer}) @@ -98,6 +152,21 @@ class HeaderBody extends StatefulWidget { return channelIdPathMap[key]; } + ///根据文本过滤 + Iterable filter(String text) { + return _body.where((element) => element.request.requestUrl.toLowerCase().contains(text)); + } + + ///复制 + HeaderBody copy({Iterable? body, bool? selected}) { + var headerBody = + HeaderBody(header, selected: selected ?? this.selected, onRemove: onRemove, proxyServer: proxyServer); + if (body != null) { + headerBody._body.addAll(body); + } + return headerBody; + } + @override State createState() { return _HeaderBodyState(); @@ -129,28 +198,31 @@ class _HeaderBodyState extends State { } Widget _hostWidget(String title) { + var host = GestureDetector( + onSecondaryLongPressDown: menu, + child: ListTile( + minLeadingWidth: 25, + leading: Icon(selected ? Icons.arrow_drop_down : Icons.arrow_right, size: 16), + dense: true, + horizontalTitleGap: 0, + visualDensity: const VisualDensity(vertical: -3.6), + title: Text(title, + textAlign: TextAlign.left, + style: const TextStyle(fontSize: 14), + maxLines: 1, + overflow: TextOverflow.ellipsis), + onTap: () { + setState(() { + selected = !selected; + }); + })); + return ColorTransition( key: transitionState, duration: const Duration(milliseconds: 1800), begin: Theme.of(context).focusColor, - child: GestureDetector( - onSecondaryLongPressDown: menu, - child: ListTile( - minLeadingWidth: 25, - leading: Icon(selected ? Icons.arrow_drop_down : Icons.arrow_right, size: 16), - dense: true, - horizontalTitleGap: 0, - visualDensity: const VisualDensity(vertical: -3.6), - title: Text(title, - textAlign: TextAlign.left, - style: const TextStyle(fontSize: 14), - maxLines: 1, - overflow: TextOverflow.ellipsis), - onTap: () { - setState(() { - selected = !selected; - }); - }))); + startAnimation: false, + child: host); } //域名右键菜单 diff --git a/lib/ui/desktop/left/search.dart b/lib/ui/desktop/left/search.dart new file mode 100644 index 0000000..85211e8 --- /dev/null +++ b/lib/ui/desktop/left/search.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +class Search extends StatelessWidget { + final Function(String val)? onSearch; + + const Search({super.key, this.onSearch}); + + @override + Widget build(BuildContext context) { + bool changing = false; + String value = ""; + return Container( + height: 32, + width: 300, + decoration: BoxDecoration( + color: Theme.of(context).hoverColor, + borderRadius: BorderRadius.circular(20), + ), + child: TextField( + cursorHeight: 15, + onChanged: (val) async { + value = val; + + if (!changing) { + changing = true; + Future.delayed(const Duration(milliseconds: 800), () { + changing = false; + onSearch?.call(value); + }); + } + }, + decoration: const InputDecoration( + contentPadding: EdgeInsets.all(0), + border: InputBorder.none, + prefixIcon: Icon(Icons.search), + hintText: 'Search', + // suffixIcon: DropdownButton( + // value: "ALL", + // icon: const Icon(Icons.arrow_drop_up), + // isDense: true, + // hint: const Text('全部', style: TextStyle(fontSize: 12)), + // items: const [ + // DropdownMenuItem(value: "JSON", child: Text('JSON', style: TextStyle(fontSize: 12))), + // DropdownMenuItem(value: "HTML", child: Text('HTML', style: TextStyle(fontSize: 12))), + // DropdownMenuItem(value: "ALL", child: Text('全部', style: TextStyle(fontSize: 12))), + // ], + // onChanged: (value) {}, + // ), + ), + ), + ); + } +} diff --git a/lib/ui/desktop/toolbar/setting/setting.dart b/lib/ui/desktop/toolbar/setting/setting.dart index c861758..cfd1052 100644 --- a/lib/ui/desktop/toolbar/setting/setting.dart +++ b/lib/ui/desktop/toolbar/setting/setting.dart @@ -35,7 +35,6 @@ class _SettingState extends State { @override Widget build(BuildContext context) { - return PopupMenuButton( tooltip: "设置", icon: const Icon(Icons.settings), @@ -65,7 +64,8 @@ class _SettingState extends State { const PopupMenuItem(padding: EdgeInsets.all(0), child: ThemeSetting(dense: true)), menuItem("域名过滤", onTap: () => _filter()), menuItem("请求重写", onTap: () => _reqeustRewrite()), - menuItem("Github", + menuItem( + "Github", onTap: () { launchUrl(Uri.parse("https://github.com/wanghongenpin/network_proxy_flutter")); }, @@ -147,7 +147,9 @@ class _PortState extends State { //失去焦点 if (!portFocus.hasFocus && textController.text != widget.proxyServer.port.toString()) { widget.proxyServer.port = int.parse(textController.text); - widget.proxyServer.restart(); + if (widget.proxyServer.isRunning) { + widget.proxyServer.restart(); + } widget.proxyServer.flushConfig(); } }); diff --git a/lib/ui/desktop/toolbar/ssl/ssl.dart b/lib/ui/desktop/toolbar/ssl/ssl.dart index cc1d019..e8155b9 100644 --- a/lib/ui/desktop/toolbar/ssl/ssl.dart +++ b/lib/ui/desktop/toolbar/ssl/ssl.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:network_proxy/network/bin/server.dart'; +import 'package:network_proxy/network/util/crts.dart'; import 'package:network_proxy/utils/ip.dart'; import 'package:path_provider/path_provider.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -51,7 +52,7 @@ class _SslState extends State { dense: true, hoverColor: Colors.transparent, focusColor: Colors.transparent, - title: const Text("安装根证书到系统"), + title: const Text("安装根证书到本机"), trailing: const Icon(Icons.arrow_right), onTap: () { pcCer(); @@ -60,13 +61,37 @@ class _SslState extends State { PopupMenuItem( padding: const EdgeInsets.all(0), child: ListTile( - title: const Text("安装根证书到手机"), + title: const Text("安装根证书到 iOS"), dense: true, hoverColor: Colors.transparent, focusColor: Colors.transparent, trailing: const Icon(Icons.arrow_right), onTap: () async { - mobileCer(await localIp()); + iosCer(await localIp()); + }), + ), + PopupMenuItem( + padding: const EdgeInsets.all(0), + child: ListTile( + title: const Text("安装根证书到 Android"), + dense: true, + hoverColor: Colors.transparent, + focusColor: Colors.transparent, + trailing: const Icon(Icons.arrow_right), + onTap: () async { + androidCer(await localIp()); + }), + ), + PopupMenuItem( + padding: const EdgeInsets.all(0), + child: ListTile( + title: const Text("下载根证书"), + dense: true, + hoverColor: Colors.transparent, + focusColor: Colors.transparent, + trailing: const Icon(Icons.arrow_right), + onTap: () async { + launchUrl(Uri.parse("http://127.0.0.1:${widget.proxyServer.port}/ssl")); }), ) ]; @@ -80,76 +105,134 @@ class _SslState extends State { builder: (BuildContext context) { return SimpleDialog( contentPadding: const EdgeInsets.all(16), - title: const Text("电脑HTTPS抓包配置", style: TextStyle(fontSize: 16)), + title: Row(children: [ + const Text("电脑HTTPS抓包配置", style: TextStyle(fontSize: 18)), + Expanded( + child: Align( + alignment: Alignment.topRight, + child: ElevatedButton.icon( + icon: const Icon(Icons.close, size: 15), + label: const Text("关闭"), + onPressed: () { + Navigator.of(context).pop(); + }))) + ]), alignment: Alignment.center, children: [ - Text( - " 安装证书到本系统,${Platform.isMacOS ? "“安装完选择“始终信任此证书”" : "选择“受信任的根证书颁发机构”"}"), + Text(" 安装证书到本系统,${Platform.isMacOS ? "“安装完选择“始终信任此证书”" : "选择“受信任的根证书颁发机构”"}"), const SizedBox(height: 10), - FilledButton( - onPressed: _installCert, child: const Text("安装证书")), + FilledButton(onPressed: _installCert, child: const Text("安装证书")), const SizedBox(height: 10), Platform.isMacOS - ? Image.network( - "https://foruda.gitee.com/images/1689323260158189316/c2d881a4_1073801.png", - width: 800, - height: 500) + ? Image.network("https://foruda.gitee.com/images/1689323260158189316/c2d881a4_1073801.png", + width: 800, height: 500) : Row(children: [ - Image.network( - "https://foruda.gitee.com/images/1689335589122168223/c904a543_1073801.png", - width: 400, - height: 400), + Image.network("https://foruda.gitee.com/images/1689335589122168223/c904a543_1073801.png", + width: 400, height: 400), const SizedBox(width: 10), - Image.network( - "https://foruda.gitee.com/images/1689335334688878324/f6aa3a3a_1073801.png", - width: 400, - height: 400) + Image.network("https://foruda.gitee.com/images/1689335334688878324/f6aa3a3a_1073801.png", + width: 400, height: 400) ]) ]); }); } - void mobileCer(String host) { + void iosCer(String host) { showDialog( context: context, builder: (BuildContext context) { return SimpleDialog( contentPadding: const EdgeInsets.all(16), - title: const Text("手机HTTPS抓包配置", style: TextStyle(fontSize: 16)), + title: Row(children: [ + const Text("iOS根证书安装指南", style: TextStyle(fontSize: 18)), + Expanded( + child: Align( + alignment: Alignment.topRight, + child: ElevatedButton.icon( + icon: const Icon(Icons.close, size: 15), + label: const Text("关闭"), + onPressed: () { + Navigator.of(context).pop(); + }))) + ]), alignment: Alignment.center, children: [ - const Text("1. 根证书安装到本系统(已完成忽略)"), - const SizedBox(height: 10), - SelectableText.rich(TextSpan( - text: - "2. 配置手机Wifi代理 Host:$host Port:${widget.proxyServer.port}")), + SelectableText.rich(TextSpan(text: "1. 配置手机Wi-Fi代理 Host:$host Port:${widget.proxyServer.port}")), const SizedBox(height: 10), const Row( children: [ - Text("3. 打开手机系统自带浏览器访问:\t"), - SelectableText.rich(TextSpan( - text: "http://proxy.pin/ssl", - style: TextStyle(decoration: TextDecoration.underline))) + Text("2. 在 iOS 设备上打开 Safari访问:\t"), + SelectableText.rich( + TextSpan(text: "http://proxy.pin/ssl", style: TextStyle(decoration: TextDecoration.underline))) ], ), const SizedBox(height: 10), - const Text("4. 打开手机设置下载安装证书信任证书\n\t 设置 > 通用 > 关于本机 > 证书信任设置"), - const SizedBox(height: 20), - const Text(" 抓微信小程序ios需要开启本地网络权限", - style: TextStyle(fontWeight: FontWeight.bold)), + const Text("3. 安装根证书并信任证书"), + const SizedBox(height: 10), + Row(children: [ + Column(children: [ + const Text("3.1 安装证书 设置 > 已下载描述文件 > 安装", style: TextStyle(fontSize: 12)), + const SizedBox(height: 10), + Image.network("https://foruda.gitee.com/images/1689346516243774963/c56bc546_1073801.png", + height: 270, width: 300) + ]), + const SizedBox(width: 10), + Column(children: [ + const Text("3.2 信任证书 设置 > 通用 > 关于本机 > 证书信任设置", style: TextStyle(fontSize: 12)), + const SizedBox(height: 10), + Image.network("https://foruda.gitee.com/images/1689346614916658100/fd9b9e41_1073801.png", + height: 270, width: 300) + ]) + ]) + ]); + }); + } + + void androidCer(String host) { + showDialog( + context: context, + builder: (BuildContext context) { + return SimpleDialog( + contentPadding: const EdgeInsets.all(16), + title: Row(children: [ + const Text("Android根证书安装指南", style: TextStyle(fontSize: 18)), + Expanded( + child: Align( + alignment: Alignment.topRight, + child: ElevatedButton.icon( + icon: const Icon(Icons.close, size: 15), + label: const Text("关闭"), + onPressed: () { + Navigator.of(context).pop(); + }))) + ]), + alignment: Alignment.center, + children: [ + SelectableText.rich(TextSpan(text: "1. 配置手机Wi-Fi代理 Host:$host Port:${widget.proxyServer.port}")), + const SizedBox(height: 10), + const Row( + children: [ + Text("2. 在 Android 设备上打开浏览器访问:\t"), + SelectableText.rich( + TextSpan(text: "http://proxy.pin/ssl", style: TextStyle(decoration: TextDecoration.underline))) + ], + ), + const SizedBox(height: 10), + const Text("2. 打开设置 -> 安全 -> 加密和凭据 -> 安装证书 -> CA 证书"), + const SizedBox(height: 10), + Image.network("https://foruda.gitee.com/images/1689352695624941051/74e3bed6_1073801.png", height: 600) ]); }); } void _installCert() async { - final String appPath = - await getApplicationSupportDirectory().then((value) => value.path); + final String appPath = await getApplicationSupportDirectory().then((value) => value.path); var caFile = File("$appPath${Platform.pathSeparator}ProxyPinCA.crt"); if (!(await caFile.exists())) { var body = await rootBundle.load('assets/certs/ca.crt'); await caFile.writeAsBytes(body.buffer.asUint8List()); } - launchUrl(Uri.file(caFile.path)); + launchUrl(Uri.file(caFile.path)).then((value) => CertificateManager.cleanCache()); } } @@ -157,9 +240,7 @@ class _Switch extends StatefulWidget { final ProxyServer proxyServer; final Function(bool val) onEnableChange; - const _Switch( - {Key? key, required this.proxyServer, required this.onEnableChange}) - : super(key: key); + const _Switch({Key? key, required this.proxyServer, required this.onEnableChange}) : super(key: key); @override State<_Switch> createState() => _SwitchState(); @@ -180,6 +261,7 @@ class _SwitchState extends State<_Switch> { widget.proxyServer.enableSsl = val; changed = true; widget.onEnableChange(val); + CertificateManager.cleanCache(); setState(() {}); }); } diff --git a/lib/ui/desktop/toolbar/toolbar.dart b/lib/ui/desktop/toolbar/toolbar.dart index d1f0404..709ca87 100644 --- a/lib/ui/desktop/toolbar/toolbar.dart +++ b/lib/ui/desktop/toolbar/toolbar.dart @@ -91,36 +91,37 @@ class _ToolbarState extends State { context: context, builder: (context) { return AlertDialog( - title: Row(children: [ - const Text("手机连接", style: TextStyle(fontSize: 18)), - Expanded( - child: Align( - alignment: Alignment.topRight, - child: ElevatedButton.icon( - icon: const Icon(Icons.close, size: 15), - label: const Text("关闭"), - onPressed: () { - Navigator.of(context).pop(); - }))) - ]), - contentPadding: const EdgeInsets.all( 10), - content: SizedBox( - height: 250, - width: 300, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - QrImageView( - backgroundColor: Colors.white, - data: "proxypin://connect?host=$host&port=${widget.proxyServer.port}", - version: QrVersions.auto, - size: 200.0, - ), - const SizedBox(height: 20), - const Text("请使用手机版扫描二维码"), - ], - )) - ); + title: Row(children: [ + const Text("手机连接", style: TextStyle(fontSize: 18)), + Expanded( + child: Align( + alignment: Alignment.topRight, + child: ElevatedButton.icon( + icon: const Icon(Icons.close, size: 15), + label: const Text("关闭"), + onPressed: () { + Navigator.of(context).pop(); + }))) + ]), + contentPadding: const EdgeInsets.all(10), + content: SizedBox( + height: 270, + width: 300, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + QrImageView( + backgroundColor: Colors.white, + data: "proxypin://connect?host=$host&port=${widget.proxyServer.port}", + version: QrVersions.auto, + size: 200.0, + ), + const SizedBox(height: 10), + Text("本机IP:$host:${widget.proxyServer.port}"), + const SizedBox(height: 10), + const Text("请使用手机版扫描二维码"), + ], + ))); }); } } diff --git a/lib/ui/mobile/menu.dart b/lib/ui/mobile/menu.dart index cb9a66e..214aef4 100644 --- a/lib/ui/mobile/menu.dart +++ b/lib/ui/mobile/menu.dart @@ -65,7 +65,7 @@ class DrawerWidget extends StatelessWidget { title: const Text("下载地址"), trailing: const Icon(Icons.arrow_right), onTap: () { - launchUrl(Uri.parse("https://gitee.com/wanghongenpin/network-proxy-flutter/releases/tag/0.0.1"), + launchUrl(Uri.parse("https://gitee.com/wanghongenpin/network-proxy-flutter/releases"), mode: LaunchMode.externalApplication); }) ], diff --git a/lib/ui/mobile/mobile.dart b/lib/ui/mobile/mobile.dart index 4973668..c96c839 100644 --- a/lib/ui/mobile/mobile.dart +++ b/lib/ui/mobile/mobile.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:network_proxy/native/vpn.dart'; import 'package:network_proxy/network/bin/server.dart'; @@ -64,8 +63,7 @@ class MobileHomeState extends State implements EventListener { @override Widget build(BuildContext context) { return Scaffold( - drawerDragStartBehavior: DragStartBehavior.down, - appBar: AppBar(centerTitle: true, title: const Text("ProxyPin", style: TextStyle(fontSize: 16)), actions: [ + appBar: AppBar(title: search(), actions: [ IconButton( tooltip: "清理", icon: const Icon(Icons.cleaning_services_outlined), @@ -105,6 +103,22 @@ class MobileHomeState extends State implements EventListener { ); } + /// 搜索框 + Widget search() { + return Padding( + padding: const EdgeInsets.only(left: 20), + child: TextField( + cursorHeight: 20, + keyboardType: TextInputType.url, + onTapOutside: (event) => FocusManager.instance.primaryFocus?.unfocus(), + onChanged: (val) { + requestStateKey.currentState?.search(val); + }, + decoration: + const InputDecoration(border: InputBorder.none, prefixIcon: Icon(Icons.search), hintText: 'Search'))); + } + + /// 检查远程连接 checkConnectTask(BuildContext context) async { int retry = 0; _connectCheckTimer = Timer.periodic(const Duration(milliseconds: 1000), (timer) async { diff --git a/lib/ui/mobile/request/list.dart b/lib/ui/mobile/request/list.dart index ac612f3..cea8ac6 100644 --- a/lib/ui/mobile/request/list.dart +++ b/lib/ui/mobile/request/list.dart @@ -29,6 +29,7 @@ class RequestListState extends State { GlobalKey requestSequenceKey = GlobalKey(); GlobalKey domainListKey = GlobalKey(); + //请求列表容器 static List container = []; @override @@ -60,10 +61,16 @@ class RequestListState extends State { domainListKey.currentState?.addResponse(response); } + ///移除 remove(List list) { container.removeWhere((element) => list.contains(element)); } + search(String text) { + requestSequenceKey.currentState?.search(text.trim()); + domainListKey.currentState?.search(text.trim()); + } + ///清理 clean() { setState(() { @@ -87,31 +94,35 @@ class RequestSequence extends StatefulWidget { } } -class RequestSequenceState extends State { +class RequestSequenceState extends State with AutomaticKeepAliveClientMixin { + ///请求和对应的row的映射 Map> indexes = HashMap(); - late Queue list = Queue(); + late List list = []; + + ///显示的请求列表 最新的在前面 + late Queue view = Queue(); bool changing = false; + //搜索关键字 + String? searchText; + @override initState() { super.initState(); - list.addAll(widget.list.reversed); + list = widget.list; + view.addAll(list.reversed); } ///添加请求 add(HttpRequest request) { - list.addFirst(request); - - //防止频繁刷新 - if (!changing) { - changing = true; - Future.delayed(const Duration(milliseconds: 200), () { - setState(() { - changing = false; - }); - }); + list.add(request); + if (!filter(request)) { + return; } + + view.addFirst(request); + changeState(); } ///添加响应 @@ -124,19 +135,63 @@ class RequestSequenceState extends State { clean() { setState(() { list.clear(); + view.clear(); indexes.clear(); }); } + void search(String text) { + text = text.toLowerCase(); + if (text == searchText) { + return; + } + + //包含从上次结果过滤 + if (text.contains(searchText ?? "")) { + searchText = text; + view.retainWhere(filter); + } else { + searchText = text; + view = Queue.of(list.where(filter).toList().reversed); + } + + changeState(); + } + + bool filter(HttpRequest request) { + if (searchText?.isNotEmpty == true) { + return request.requestUrl.toLowerCase().contains(searchText!); + } + return true; + } + + changeState() { + //防止频繁刷新 + if (!changing) { + changing = true; + Future.delayed(const Duration(milliseconds: 300), () { + setState(() { + changing = false; + }); + }); + } + } + + @override + bool get wantKeepAlive => true; + @override Widget build(BuildContext context) { + super.build(context); + return ListView.separated( + cacheExtent: 1000, separatorBuilder: (context, index) => Divider(height: 0.5, color: Theme.of(context).focusColor), - itemCount: list.length, + itemCount: view.length, itemBuilder: (context, index) { GlobalKey key = GlobalKey(); - indexes[list.elementAt(index)] = key; - return RequestRow(key: key, request: list.elementAt(index), proxyServer: widget.proxyServer); + indexes[view.elementAt(index)] = key; + return RequestRow(key: key, request: view.elementAt(index), proxyServer: widget.proxyServer); }); } } @@ -155,7 +210,7 @@ class DomainList extends StatefulWidget { } } -class DomainListState extends State { +class DomainListState extends State with AutomaticKeepAliveClientMixin { GlobalKey requestSequenceKey = GlobalKey(); //域名和对应请求列表的映射 @@ -169,6 +224,9 @@ class DomainListState extends State { HostAndPort? showHostAndPort; bool changing = false; + //搜索关键字 + String? searchText; + @override initState() { super.initState(); @@ -200,7 +258,11 @@ class DomainListState extends State { requestSequenceKey.currentState?.add(request); } - this.list = [...container].reversed.toList(); + if (!filter(request.hostAndPort!)) { + return; + } + + this.list = [...container.where(filter)].reversed.toList(); //防止频繁刷新 if (!changing) { changing = true; @@ -226,31 +288,59 @@ class DomainListState extends State { }); } + void search(String text) { + text = text.toLowerCase(); + setState(() { + var contains = text.contains(searchText ?? ""); + searchText = text.toLowerCase(); + if (contains) { + //包含从上次结果过滤 + list.retainWhere(filter); + } else { + list = List.of(container.where(filter).toList().reversed); + } + }); + } + + bool filter(HostAndPort hostAndPort) { + if (searchText?.isNotEmpty == true) { + return hostAndPort.domain.toLowerCase().contains(searchText!); + } + return true; + } + + @override + bool get wantKeepAlive => true; + @override Widget build(BuildContext context) { + super.build(context); return ListView.separated( separatorBuilder: (context, index) => Divider(height: 0.5, color: Theme.of(context).focusColor), + cacheExtent: 1000, itemCount: list.length, - itemBuilder: (ctx, index) { - var time = - formatDate(containerMap[list.elementAt(index)]!.last.requestTime, [m, '/', d, ' ', HH, ':', nn, ':', ss]); - return ListTile( - title: Text(list.elementAt(index).domain, maxLines: 1, overflow: TextOverflow.ellipsis), - trailing: const Icon(Icons.chevron_right), - subtitle: Text("最后请求时间: $time, 次数: ${containerMap[list.elementAt(index)]!.length}", - maxLines: 1, overflow: TextOverflow.ellipsis), - onLongPress: () => menu(index), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) { - showHostAndPort = list.elementAt(index); - return Scaffold( - appBar: AppBar(title: const Text("请求列表")), - body: RequestSequence( - key: requestSequenceKey, - list: containerMap[list.elementAt(index)]!, - proxyServer: widget.proxyServer)); - })); - }); + itemBuilder: (ctx, index) => title(index)); + } + + Widget title(int index) { + var time = + formatDate(containerMap[list.elementAt(index)]!.last.requestTime, [m, '/', d, ' ', HH, ':', nn, ':', ss]); + return ListTile( + title: Text(list.elementAt(index).domain, maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: const Icon(Icons.chevron_right), + subtitle: Text("最后请求时间: $time, 次数: ${containerMap[list.elementAt(index)]!.length}", + maxLines: 1, overflow: TextOverflow.ellipsis), + onLongPress: () => menu(index), + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) { + showHostAndPort = list.elementAt(index); + return Scaffold( + appBar: AppBar(title: const Text("请求列表")), + body: RequestSequence( + key: requestSequenceKey, + list: containerMap[list.elementAt(index)]!, + proxyServer: widget.proxyServer)); + })); }); } diff --git a/lib/ui/mobile/setting/ssl.dart b/lib/ui/mobile/setting/ssl.dart index 4465439..b7389d5 100644 --- a/lib/ui/mobile/setting/ssl.dart +++ b/lib/ui/mobile/setting/ssl.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:network_proxy/network/bin/server.dart'; +import 'package:network_proxy/network/util/crts.dart'; import 'package:url_launcher/url_launcher.dart'; class MobileSslWidget extends StatefulWidget { @@ -41,6 +42,7 @@ class _MobileSslState extends State { widget.proxyServer.enableSsl = val; if (widget.onEnableChange != null) widget.onEnableChange!(val); changed = true; + CertificateManager.cleanCache(); setState(() {}); }), ExpansionTile( @@ -82,6 +84,15 @@ class _MobileSslState extends State { } void _downloadCert() async { + if (!widget.proxyServer.isRunning) { + showDialog( + context: context, + builder: (context) { + return const Text("请先启动代理服务器"); + }); + return; + } launchUrl(Uri.parse("http://127.0.0.1:${widget.proxyServer.port}/ssl"), mode: LaunchMode.externalApplication); + CertificateManager.cleanCache(); } }