From d4be1bc0e0a71fd06f8b683900f99c5228d1243e Mon Sep 17 00:00:00 2001 From: wanghongenpin <178070584@qq.com> Date: Thu, 19 Oct 2023 17:13:35 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=8B=E6=9C=BA=E7=AB=AF=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/network/util/script_manager.dart | 28 ++ lib/ui/desktop/toolbar/setting/script.dart | 38 +-- lib/ui/mobile/menu.dart | 5 + lib/ui/mobile/mobile.dart | 1 + lib/ui/mobile/request/history.dart | 5 +- lib/ui/mobile/setting/script.dart | 370 +++++++++++++++++++++ 6 files changed, 412 insertions(+), 35 deletions(-) create mode 100644 lib/ui/mobile/setting/script.dart diff --git a/lib/network/util/script_manager.dart b/lib/network/util/script_manager.dart index 4cd7b54..b678cdc 100644 --- a/lib/network/util/script_manager.dart +++ b/lib/network/util/script_manager.dart @@ -10,6 +10,34 @@ import 'package:path_provider/path_provider.dart'; /// 2023/10/06 /// js脚本 class ScriptManager { + static String template = """ +// 在请求到达服务器之前,调用此函数,您可以在此处修改请求数据 +// 例如Add/Update/Remove:Queries、Headers、Body +async function onRequest(context, request) { + console.log(request.url); + //URL参数 + //request.queries["name"] = "value"; + // 更新或添加新标头 + //request.headers["X-New-Headers"] = "My-Value"; + + // Update Body 使用fetch API请求接口,具体文档可网上搜索fetch API + //response.body = await fetch('https://www.baidu.com/').then(response => response.text()); + return request; +} + +// 在将响应数据发送到客户端之前,调用此函数,您可以在此处修改响应数据 +async function onResponse(context, request, response) { + // 更新或添加新标头 + // response.headers["Name"] = "Value"; + // response.statusCode = 200; + + //var body = JSON.parse(response.body); + //body['key'] = "value"; + //response.body = JSON.stringify(body); + return response; +} + """; + static String separator = Platform.pathSeparator; static ScriptManager? _instance; bool enabled = true; diff --git a/lib/ui/desktop/toolbar/setting/script.dart b/lib/ui/desktop/toolbar/setting/script.dart index 1b133ec..02fb11b 100644 --- a/lib/ui/desktop/toolbar/setting/script.dart +++ b/lib/ui/desktop/toolbar/setting/script.dart @@ -199,34 +199,6 @@ class ScriptEdit extends StatefulWidget { } class _ScriptEditState extends State { - static String template = """ -// 在请求到达服务器之前,调用此函数,您可以在此处修改请求数据 -// 例如Add/Update/Remove:Queries、Headers、Body -async function onRequest(context, request) { - console.log(request.url); - //URL参数 - //request.queries["name"] = "value"; - // 更新或添加新标头 - //request.headers["X-New-Headers"] = "My-Value"; - - // Update Body 使用fetch API请求接口,具体文档可网上搜索fetch API - //response.body = await fetch('https://www.baidu.com/').then(response => response.text()); - return request; -} - -// 在将响应数据发送到客户端之前,调用此函数,您可以在此处修改响应数据 -async function onResponse(context, request, response) { - // 更新或添加新标头 - // response.headers["Name"] = "Value"; - // response.statusCode = 200; - - //var body = JSON.parse(response.body); - //body['key'] = "value"; - //response.body = JSON.stringify(body); - return response; -} - """; - late CodeController script; late TextEditingController nameController; late TextEditingController urlController; @@ -234,7 +206,7 @@ async function onResponse(context, request, response) { @override void initState() { super.initState(); - script = CodeController(language: javascript, text: widget.script ?? template); + script = CodeController(language: javascript, text: widget.script ?? ScriptManager.template); nameController = TextEditingController(text: widget.scriptItem?.name); urlController = TextEditingController(text: widget.scriptItem?.url); } @@ -386,11 +358,15 @@ class _ScriptListState extends State { PopupMenuItem( height: 35, child: const Text("编辑"), - onTap: () { + onTap: () async { + String script = await (await ScriptManager.instance).getScript(list[index]); + if (!context.mounted) { + return; + } showDialog( barrierDismissible: false, context: context, - builder: (_) => ScriptEdit(scriptItem: list[index])).then((value) { + builder: (_) => ScriptEdit(scriptItem: list[index], script: script)).then((value) { if (value != null) { setState(() {}); } diff --git a/lib/ui/mobile/menu.dart b/lib/ui/mobile/menu.dart index 6dfee8c..9adac2c 100644 --- a/lib/ui/mobile/menu.dart +++ b/lib/ui/mobile/menu.dart @@ -16,6 +16,7 @@ import 'package:network_proxy/ui/mobile/request/list.dart'; import 'package:network_proxy/ui/mobile/setting/app_whitelist.dart'; import 'package:network_proxy/ui/mobile/setting/filter.dart'; import 'package:network_proxy/ui/mobile/setting/request_rewrite.dart'; +import 'package:network_proxy/ui/mobile/setting/script.dart'; import 'package:network_proxy/ui/mobile/setting/ssl.dart'; import 'package:network_proxy/ui/mobile/setting/theme.dart'; import 'package:network_proxy/utils/ip.dart'; @@ -78,6 +79,10 @@ class DrawerWidget extends StatelessWidget { title: const Text("请求重写"), trailing: const Icon(Icons.arrow_right), onTap: () => navigator(context, MobileRequestRewrite(configuration: proxyServer.configuration))), + ListTile( + title: const Text("脚本"), + trailing: const Icon(Icons.arrow_right), + onTap: () => navigator(context, const MobileScript())), ListTile( title: const Text("Github"), trailing: const Icon(Icons.arrow_right), diff --git a/lib/ui/mobile/mobile.dart b/lib/ui/mobile/mobile.dart index 10981f9..bd357ec 100644 --- a/lib/ui/mobile/mobile.dart +++ b/lib/ui/mobile/mobile.dart @@ -95,6 +95,7 @@ class MobileHomeState extends State implements EventListener { proxyServer: proxyServer, size: 36, startup: false, + serverLaunch: false, onStart: () => Vpn.startVpn("127.0.0.1", proxyServer.port, proxyServer.configuration.appWhitelist), onStop: () => Vpn.stopVpn())), ), diff --git a/lib/ui/mobile/request/history.dart b/lib/ui/mobile/request/history.dart index 7b8a8be..ac95493 100644 --- a/lib/ui/mobile/request/history.dart +++ b/lib/ui/mobile/request/history.dart @@ -92,15 +92,12 @@ class _MobileHistoryState extends State { //导入har import(HistoryStorage storage) async { - const XTypeGroup typeGroup = XTypeGroup( - label: 'Har', - ); + const XTypeGroup typeGroup = XTypeGroup(label: 'Har', extensions: ['har']); final XFile? file = await openFile(acceptedTypeGroups: [typeGroup]); if (file == null) { return; } - print(file); try { var historyItem = await storage.addHarFile(file); setState(() { diff --git a/lib/ui/mobile/setting/script.dart b/lib/ui/mobile/setting/script.dart new file mode 100644 index 0000000..dcedbca --- /dev/null +++ b/lib/ui/mobile/setting/script.dart @@ -0,0 +1,370 @@ +import 'dart:convert'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_code_editor/flutter_code_editor.dart'; +import 'package:flutter_highlight/themes/monokai-sublime.dart'; +import 'package:flutter_toastr/flutter_toastr.dart'; +import 'package:highlight/languages/javascript.dart'; +import 'package:network_proxy/network/util/logger.dart'; +import 'package:network_proxy/network/util/script_manager.dart'; +import 'package:network_proxy/ui/component/utils.dart'; +import 'package:network_proxy/ui/component/widgets.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// @author wanghongen +/// 2023/10/19 +/// js脚本 +class MobileScript extends StatefulWidget { + const MobileScript({super.key}); + + @override + State createState() => _MobileScriptState(); +} + +bool _refresh = false; + +/// 刷新脚本 +void _refreshScript() { + if (_refresh) { + return; + } + _refresh = true; + Future.delayed(const Duration(milliseconds: 1500), () async { + _refresh = false; + (await ScriptManager.instance).flushConfig(); + }); +} + +class _MobileScriptState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("脚本", style: TextStyle(fontSize: 16))), + body: Padding( + padding: const EdgeInsets.only(left: 15, right: 10), + child: futureWidget( + ScriptManager.instance, + loading: true, + (data) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Row(children: [ + SizedBox( + width: 300, + child: SwitchWidget( + title: '启用脚本工具', + subtitle: "使用 JavaScript 修改请求和响应", + value: data.enabled, + onChanged: (value) { + data.enabled = value; + _refreshScript(); + }, + )), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const SizedBox(width: 10), + FilledButton( + style: ElevatedButton.styleFrom(padding: const EdgeInsets.only(left: 20, right: 20)), + onPressed: scriptEdit, + child: const Text("添加"), + ), + const SizedBox(width: 10), + OutlinedButton( + style: ElevatedButton.styleFrom(padding: const EdgeInsets.only(left: 20, right: 20)), + onPressed: import, + child: const Text("导入"), + ) + ], + )), + const SizedBox(width: 15) + ]), + const SizedBox(height: 5), + Container( + padding: const EdgeInsets.only(top: 10), + constraints: const BoxConstraints(maxHeight: 500, minHeight: 300), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.withOpacity(0.2)), + color: Colors.white, + backgroundBlendMode: BlendMode.colorBurn), + child: SingleChildScrollView( + child: Column(children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Container( + width: 200, padding: const EdgeInsets.only(left: 10), child: const Text("名称")), + const SizedBox(width: 50, child: Text("启用", textAlign: TextAlign.center)), + const VerticalDivider(), + const Expanded(child: Text("URL")), + ], + ), + const Divider(thickness: 0.5), + ScriptList(scripts: data.list), + ]))), + ])))); + } + + //导入js + import() async { + final XFile? file = await openFile(); + if (file == null) { + return; + } + + try { + var json = jsonDecode(await file.readAsString()); + var scriptItem = ScriptItem.fromJson(json); + (await ScriptManager.instance).addScript(scriptItem, json['script']); + _refreshScript(); + if (context.mounted) { + FlutterToastr.show("导入成功", context); + } + setState(() {}); + } catch (e, t) { + logger.e('导入失败 $file', error: e, stackTrace: t); + if (context.mounted) { + FlutterToastr.show("导入失败 $e", context); + } + } + } + + /// 添加脚本 + scriptEdit() async { + Navigator.of(context).push(MaterialPageRoute(builder: (context) => const ScriptEdit())).then((value) { + if (value != null) { + setState(() {}); + } + }); + } +} + +/// 编辑脚本 +class ScriptEdit extends StatefulWidget { + final ScriptItem? scriptItem; + final String? script; + + const ScriptEdit({Key? key, this.scriptItem, this.script}) : super(key: key); + + @override + State createState() => _ScriptEditState(); +} + +class _ScriptEditState extends State { + late CodeController script; + late TextEditingController nameController; + late TextEditingController urlController; + + @override + void initState() { + super.initState(); + script = CodeController(language: javascript, text: widget.script ?? ScriptManager.template); + nameController = TextEditingController(text: widget.scriptItem?.name); + urlController = TextEditingController(text: widget.scriptItem?.url); + } + + @override + void dispose() { + script.dispose(); + nameController.dispose(); + urlController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + GlobalKey formKey = GlobalKey(); + return Scaffold( + appBar: AppBar( + title: Row(children: [ + const Text("编辑脚本", style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + const SizedBox(width: 10), + Text.rich(TextSpan( + text: '使用文档', + style: const TextStyle(color: Colors.blue, fontSize: 14), + recognizer: TapGestureRecognizer() + ..onTap = () => launchUrl( + Uri.parse('https://gitee.com/wanghongenpin/network-proxy-flutter/wikis/%E8%84%9A%E6%9C%AC')))), + ]), + actions: [ + TextButton( + onPressed: () async { + if (!(formKey.currentState as FormState).validate()) { + FlutterToastr.show("名称和URL不能为空", context, position: FlutterToastr.top); + return; + } + //新增 + if (widget.scriptItem == null) { + var scriptItem = ScriptItem(true, nameController.text, urlController.text); + (await ScriptManager.instance).addScript(scriptItem, script.text); + } else { + widget.scriptItem?.name = nameController.text; + widget.scriptItem?.url = urlController.text; + (await ScriptManager.instance).updateScript(widget.scriptItem!, script.text); + } + + _refreshScript(); + if (context.mounted) { + Navigator.of(context).maybePop(true); + } + }, + child: const Text("保存")), + ]), + body: Padding( + padding: const EdgeInsets.only(left: 15, right: 10, bottom: 20), + child: Form( + key: formKey, + child: ListView( + children: [ + textField("名称:", nameController, "请输入名称"), + const SizedBox(height: 10), + textField("URL:", urlController, "github.com/api/*", keyboardType: TextInputType.url), + const SizedBox(height: 10), + const Text("脚本:"), + const SizedBox(height: 5), + SizedBox( + height: 400, + child: CodeTheme( + data: CodeThemeData(styles: monokaiSublimeTheme), + child: SingleChildScrollView( + child: CodeField(textStyle: const TextStyle(fontSize: 13), controller: script)))) + ], + )))); + } + + Widget textField(String label, TextEditingController controller, String hint, {TextInputType? keyboardType}) { + return Row(children: [ + SizedBox(width: 50, child: Text(label)), + Expanded( + child: TextFormField( + controller: controller, + validator: (val) => val?.isNotEmpty == true ? null : "", + keyboardType: keyboardType, + decoration: InputDecoration( + hintText: hint, + errorStyle: const TextStyle(height: 0, fontSize: 0), + focusedBorder: focusedBorder(), + isDense: true, + constraints: const BoxConstraints(maxHeight: 38), + border: const OutlineInputBorder()), + )) + ]); + } + + InputBorder focusedBorder() { + return OutlineInputBorder(borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2)); + } +} + +/// 脚本列表 +class ScriptList extends StatefulWidget { + final List scripts; + + const ScriptList({Key? key, required this.scripts}) : super(key: key); + + @override + State createState() => _ScriptListState(); +} + +class _ScriptListState extends State { + @override + Widget build(BuildContext context) { + return Column(children: rows(widget.scripts)); + } + + List rows(List list) { + return List.generate(list.length, (index) { + return Ink( + child: GestureDetector( + onDoubleTap: () async { + String script = await (await ScriptManager.instance).getScript(list[index]); + if (!context.mounted) { + return; + } + Navigator.of(context) + .push(MaterialPageRoute(builder: (context) => ScriptEdit(scriptItem: list[index], script: script))) + .then((value) { + if (value != null) { + setState(() {}); + } + }); + }, + onLongPressDown: (details) { + showContextMenu(context, details.globalPosition, items: [ + PopupMenuItem( + height: 35, + child: const Text("编辑"), + onTap: () async { + String script = await (await ScriptManager.instance).getScript(list[index]); + if (!context.mounted) { + return; + } + Navigator.of(context) + .push(MaterialPageRoute( + builder: (context) => ScriptEdit(scriptItem: list[index], script: script))) + .then((value) { + if (value != null) { + setState(() {}); + } + }); + }), + PopupMenuItem(height: 35, child: const Text("分享"), onTap: () => export(list[index])), + PopupMenuItem( + height: 35, + child: list[index].enabled ? const Text("禁用") : const Text("启用"), + onTap: () { + list[index].enabled = !list[index].enabled; + setState(() {}); + }), + const PopupMenuDivider(), + PopupMenuItem( + height: 35, + child: const Text("删除"), + onTap: () async { + (await ScriptManager.instance).removeScript(index); + _refreshScript(); + setState(() {}); + if (context.mounted) FlutterToastr.show('删除成功', context); + }), + ]); + }, + child: Container( + color: index.isEven ? Colors.grey.withOpacity(0.1) : null, + height: 30, + padding: const EdgeInsets.all(5), + child: Row( + children: [ + SizedBox(width: 200, child: Text(list[index].name!, style: const TextStyle(fontSize: 13))), + SizedBox( + width: 40, + child: Transform.scale( + scale: 0.65, + child: SwitchWidget( + value: list[index].enabled, + onChanged: (val) { + list[index].enabled = val; + _refreshScript(); + }))), + const SizedBox(width: 20), + Expanded(child: Text(list[index].url, style: const TextStyle(fontSize: 13))), + ], + )))); + }); + } + + //导出js + export(ScriptItem item) async { + //文件名称 + String fileName = '${item.name}.json'; + var json = item.toJson(); + json.remove("scriptPath"); + json['script'] = await (await ScriptManager.instance).getScript(item); + final XFile file = XFile.fromData(utf8.encode(jsonEncode(json)), mimeType: 'json'); + Share.shareXFiles([file], subject: fileName); + } +}