From 4aa0d033564c7402a2ea26153a7eafcaff9a597e Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Tue, 29 Jul 2025 22:51:04 +0800 Subject: [PATCH] mobile request map --- .../proxy/vpn/util/ProcessInfoManager.kt | 5 + lib/ui/content/panel.dart | 13 + lib/ui/desktop/request/request.dart | 16 +- .../desktop/toolbar/setting/request_map.dart | 11 +- lib/ui/desktop/toolbar/setting/script.dart | 7 +- lib/ui/mobile/menu/drawer.dart | 12 +- lib/ui/mobile/menu/me.dart | 7 + lib/ui/mobile/request/request.dart | 11 +- lib/ui/mobile/setting/request_map.dart | 588 ++++++++++++++++++ .../mobile/setting/request_map/map_local.dart | 399 ++++++++++++ .../mobile/setting/request_map/map_scipt.dart | 69 ++ lib/ui/mobile/setting/request_rewrite.dart | 15 +- .../setting/rewrite/rewrite_replace.dart | 6 +- lib/ui/mobile/setting/script.dart | 5 +- 14 files changed, 1133 insertions(+), 31 deletions(-) create mode 100644 lib/ui/mobile/setting/request_map.dart create mode 100644 lib/ui/mobile/setting/request_map/map_local.dart create mode 100644 lib/ui/mobile/setting/request_map/map_scipt.dart diff --git a/android/app/src/main/kotlin/com/network/proxy/vpn/util/ProcessInfoManager.kt b/android/app/src/main/kotlin/com/network/proxy/vpn/util/ProcessInfoManager.kt index 2c31d6a..feb3775 100644 --- a/android/app/src/main/kotlin/com/network/proxy/vpn/util/ProcessInfoManager.kt +++ b/android/app/src/main/kotlin/com/network/proxy/vpn/util/ProcessInfoManager.kt @@ -124,6 +124,11 @@ class ProcessInfoManager private constructor() { } } + if (host == null || localPort <= 0 || ProxyVpnService.host == null || ProxyVpnService.port <= 0) { + Log.w("ProcessInfoManager", "Invalid host or local port: $host:$localPort or ProxyVpnService not initialized") + return null + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { return withContext(Dispatchers.IO) { val localAddress = InetSocketAddress(host, localPort) diff --git a/lib/ui/content/panel.dart b/lib/ui/content/panel.dart index 90f08ac..45c44d4 100644 --- a/lib/ui/content/panel.dart +++ b/lib/ui/content/panel.dart @@ -29,7 +29,9 @@ import 'package:proxypin/ui/component/state_component.dart'; import 'package:proxypin/ui/component/utils.dart'; import 'package:proxypin/ui/component/widgets.dart'; import 'package:proxypin/ui/configuration.dart'; +import 'package:proxypin/ui/mobile/menu/drawer.dart'; import 'package:proxypin/ui/mobile/request/request_editor.dart'; +import 'package:proxypin/ui/mobile/setting/request_map.dart'; import 'package:proxypin/utils/lang.dart'; import 'package:proxypin/utils/platform.dart'; import 'package:proxypin/utils/python.dart'; @@ -147,6 +149,17 @@ class NetworkTabState extends State with SingleTickerProvi request: widget.request.get(), proxyServer: widget.proxyServer))); }); }), + PopupMenuItem( + child: Text(localizations.requestMap), + onTap: () { + WidgetsBinding.instance.addPostFrameCallback((_) { + navigator( + context, + MobileRequestMapEdit( + url: widget.request.get()?.domainPath, + title: widget.request.get()?.hostAndPort?.host)); + }); + }), CustomPopupMenuItem( padding: const EdgeInsets.only(left: 10), child: Text(localizations.copyRawRequest), diff --git a/lib/ui/desktop/request/request.dart b/lib/ui/desktop/request/request.dart index 8071fd5..2246abb 100644 --- a/lib/ui/desktop/request/request.dart +++ b/lib/ui/desktop/request/request.dart @@ -35,6 +35,7 @@ import 'package:proxypin/ui/component/widgets.dart'; import 'package:proxypin/ui/configuration.dart'; import 'package:proxypin/ui/content/panel.dart'; import 'package:proxypin/ui/desktop/request/repeat.dart'; +import 'package:proxypin/ui/desktop/toolbar/setting/request_map.dart'; import 'package:proxypin/ui/desktop/toolbar/setting/script.dart'; import 'package:proxypin/ui/desktop/widgets/highlight.dart'; import 'package:proxypin/utils/curl.dart'; @@ -150,7 +151,7 @@ class _RequestWidgetState extends State { setState(() {}); } - contextualMenu() { + void contextualMenu() { Menu menu = Menu(items: [ MenuItem( label: localizations.copyUrl, @@ -201,6 +202,14 @@ class _RequestWidgetState extends State { }), MenuItem.separator(), MenuItem(label: localizations.requestRewrite, onClick: (_) => showRequestRewriteDialog(context, widget.request)), + MenuItem( + label: localizations.requestMap, + onClick: (_) async { + showDialog( + context: context, + builder: (context) => + RequestMapEdit(url: widget.request.domainPath, title: widget.request.hostAndPort?.host)); + }), MenuItem( label: localizations.script, onClick: (_) async { @@ -211,7 +220,9 @@ class _RequestWidgetState extends State { String? script = scriptItem == null ? null : await scriptManager.getScript(scriptItem); if (!mounted) return; showDialog( - context: context, builder: (context) => ScriptEdit(scriptItem: scriptItem, script: script, url: url)); + context: context, + builder: (context) => ScriptEdit( + scriptItem: scriptItem, script: script, url: url, title: widget.request.hostAndPort?.host)); }), MenuItem.separator(), MenuItem( @@ -361,7 +372,6 @@ class _RequestWidgetState extends State { autoReadRequests.add(widget.request.requestId); } - //切换选中的节点 if (selectedState?.mounted == true && selectedState != this) { selectedState?.setState(() { diff --git a/lib/ui/desktop/toolbar/setting/request_map.dart b/lib/ui/desktop/toolbar/setting/request_map.dart index 2ab2a4d..15d2c32 100644 --- a/lib/ui/desktop/toolbar/setting/request_map.dart +++ b/lib/ui/desktop/toolbar/setting/request_map.dart @@ -93,7 +93,8 @@ class _RequestMapPageState extends State { width: 350, child: ListTile( title: Text("${localizations.enable} ${localizations.requestMap}"), - subtitle: Text(localizations.requestMapDescribe, style: const TextStyle(fontSize: 12)), + subtitle: + Text(localizations.requestMapDescribe, style: const TextStyle(fontSize: 12)), trailing: SwitchWidget( value: data.enabled, scale: 0.8, @@ -438,8 +439,10 @@ class RequestMapEdit extends StatefulWidget { final RequestMapRule? rule; final RequestMapItem? item; final int? windowId; + final String? url; + final String? title; - const RequestMapEdit({super.key, this.rule, this.windowId, this.item}); + const RequestMapEdit({super.key, this.rule, this.windowId, this.item, this.url, this.title}); @override State createState() { @@ -462,9 +465,9 @@ class _RequestMapEditState extends State { @override void initState() { super.initState(); - rule = widget.rule ?? RequestMapRule(url: '', type: RequestMapType.local); + rule = widget.rule ?? RequestMapRule(url: widget.url ?? '', type: RequestMapType.local); mapType = rule.type; - nameInput = TextEditingController(text: rule.name); + nameInput = TextEditingController(text: rule.name ?? widget.title); urlInput = TextEditingController(text: rule.url); } diff --git a/lib/ui/desktop/toolbar/setting/script.dart b/lib/ui/desktop/toolbar/setting/script.dart index bdb30e2..85e944d 100644 --- a/lib/ui/desktop/toolbar/setting/script.dart +++ b/lib/ui/desktop/toolbar/setting/script.dart @@ -315,8 +315,9 @@ class ScriptEdit extends StatefulWidget { final ScriptItem? scriptItem; final String? script; final String? url; + final String? title; - const ScriptEdit({super.key, this.scriptItem, this.script, this.url}); + const ScriptEdit({super.key, this.scriptItem, this.script, this.url, this.title}); @override State createState() => _ScriptEditState(); @@ -333,7 +334,7 @@ class _ScriptEditState extends State { void initState() { super.initState(); script = CodeController(language: javascript, text: widget.script ?? ScriptManager.template); - nameController = TextEditingController(text: widget.scriptItem?.name); + nameController = TextEditingController(text: widget.scriptItem?.name ?? widget.title); urlController = TextEditingController(text: widget.scriptItem?.url ?? widget.url); } @@ -646,7 +647,7 @@ class _ScriptListState extends State { path = await DesktopMultiWindow.invokeMethod(0, "saveFile", {"fileName": fileName}); WindowController.fromWindowId(widget.windowId).show(); } else { - path = await FilePicker.platform.saveFile(fileName: fileName); + path = await FilePicker.platform.saveFile(fileName: fileName); } if (path == null) { return; diff --git a/lib/ui/mobile/menu/drawer.dart b/lib/ui/mobile/menu/drawer.dart index 27f89c3..7681ec9 100644 --- a/lib/ui/mobile/menu/drawer.dart +++ b/lib/ui/mobile/menu/drawer.dart @@ -23,6 +23,7 @@ import 'package:proxypin/network/components/manager/request_block_manager.dart'; import 'package:proxypin/network/components/manager/request_rewrite_manager.dart'; import 'package:proxypin/network/http/http.dart'; import 'package:proxypin/storage/histories.dart'; +import 'package:proxypin/ui/mobile/setting/request_map.dart'; import 'package:proxypin/ui/toolbox/toolbox.dart'; import 'package:proxypin/ui/component/utils.dart'; import 'package:proxypin/ui/configuration.dart'; @@ -54,12 +55,9 @@ class DrawerWidget extends StatelessWidget { return Drawer( backgroundColor: Theme.of(context).cardColor, child: ListView( - padding: EdgeInsets.zero, + // padding: EdgeInsets.zero, children: [ - DrawerHeader( - decoration: BoxDecoration(color: Theme.of(context).colorScheme.primaryContainer), - child: const Text(''), - ), + SizedBox(height: 15), ListTile( leading: const Icon(Icons.favorite), title: Text(localizations.favorites), @@ -108,6 +106,10 @@ class DrawerWidget extends StatelessWidget { navigator(context, MobileRequestRewrite(requestRewrites: requestRewrites)); } }), + ListTile( + title: Text(localizations.requestMap), + leading: Icon(Icons.swap_horiz_outlined), + onTap: () => navigator(context, MobileRequestMapPage())), ListTile( title: Text(localizations.script), leading: const Icon(Icons.code), diff --git a/lib/ui/mobile/menu/me.dart b/lib/ui/mobile/menu/me.dart index 8f4c024..293b041 100644 --- a/lib/ui/mobile/menu/me.dart +++ b/lib/ui/mobile/menu/me.dart @@ -35,6 +35,8 @@ import 'package:proxypin/ui/mobile/setting/script.dart'; import 'package:proxypin/ui/mobile/setting/ssl.dart'; import 'package:proxypin/ui/mobile/widgets/about.dart'; +import '../setting/request_map.dart'; + /// @author wanghongen /// 2024/9/30 class MePage extends StatefulWidget { @@ -124,6 +126,11 @@ class _MePageState extends State { navigator(context, MobileRequestRewrite(requestRewrites: requestRewrites)); } }), + ListTile( + title: Text(localizations.requestMap), + leading: Icon(Icons.swap_horiz_outlined, color: color), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: () => navigator(context, MobileRequestMapPage())), ListTile( title: Text(localizations.script), leading: Icon(Icons.javascript_outlined, color: color), diff --git a/lib/ui/mobile/request/request.dart b/lib/ui/mobile/request/request.dart index aa33943..d92db0f 100644 --- a/lib/ui/mobile/request/request.dart +++ b/lib/ui/mobile/request/request.dart @@ -278,8 +278,11 @@ class RequestRowState extends State { String? script = scriptItem == null ? null : await scriptManager.getScript(scriptItem); var pageRoute = MaterialPageRoute( - builder: (context) => - ScriptEdit(scriptItem: scriptItem, script: script, url: scriptItem?.url ?? url)); + builder: (context) => ScriptEdit( + scriptItem: scriptItem, + script: script, + url: scriptItem?.url ?? url, + title: request.hostAndPort?.host)); Navigator.push(getContext(), pageRoute); }, @@ -344,7 +347,7 @@ class RequestRowState extends State { } //显示高级重发 - showCustomRepeat(HttpRequest request) async { + Future showCustomRepeat(HttpRequest request) async { await Navigator.maybePop(availableContext); var pageRoute = MaterialPageRoute( builder: (context) => futureWidget(SharedPreferences.getInstance(), @@ -353,7 +356,7 @@ class RequestRowState extends State { Navigator.push(getContext(), pageRoute); } - onRepeat(HttpRequest request) { + void onRepeat(HttpRequest request) { var httpRequest = request.copy(uri: request.requestUrl); var proxyInfo = widget.proxyServer.isRunning ? ProxyInfo.of("127.0.0.1", widget.proxyServer.port) : null; HttpClients.proxyRequest(httpRequest, proxyInfo: proxyInfo); diff --git a/lib/ui/mobile/setting/request_map.dart b/lib/ui/mobile/setting/request_map.dart new file mode 100644 index 0000000..24a4c4d --- /dev/null +++ b/lib/ui/mobile/setting/request_map.dart @@ -0,0 +1,588 @@ +import 'dart:convert'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_toastr/flutter_toastr.dart'; +import 'package:proxypin/l10n/app_localizations.dart'; +import 'package:proxypin/network/components/manager/request_map_manager.dart'; +import 'package:proxypin/ui/component/app_dialog.dart'; +import 'package:proxypin/ui/component/utils.dart'; +import 'package:proxypin/ui/component/widgets.dart'; +import 'package:proxypin/ui/mobile/setting/request_map/map_local.dart'; +import 'package:proxypin/ui/mobile/setting/request_map/map_scipt.dart'; +import 'package:proxypin/utils/lang.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../../../network/util/logger.dart'; +import '../../../utils/platform.dart'; + +bool _refresh = false; + +/// 刷新配置 +void _refreshConfig({bool force = false}) { + if (_refresh && !force) { + return; + } + _refresh = true; + Future.delayed(const Duration(milliseconds: 1500), () async { + _refresh = false; + await RequestMapManager.instance.then((manager) => manager.flushConfig()); + }); +} + +class MobileRequestMapPage extends StatefulWidget { + const MobileRequestMapPage({super.key}); + + @override + State createState() => _RequestMapPageState(); +} + +class _RequestMapPageState extends State { + AppLocalizations get localizations => AppLocalizations.of(context)!; + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(localizations.requestMap, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + toolbarHeight: 36, + centerTitle: true), + body: Padding( + padding: const EdgeInsets.all(10), + child: futureWidget( + RequestMapManager.instance, + loading: true, + (data) => Column(children: [ + Row(children: [ + Expanded( + child: ListTile( + title: Text("${localizations.enable} ${localizations.requestMap}"), + subtitle: Text(localizations.requestMapDescribe, style: const TextStyle(fontSize: 12)), + trailing: SwitchWidget( + value: data.enabled, + scale: 0.8, + onChanged: (value) { + data.enabled = value; + _refreshConfig(); + }))), + ]), + Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + const SizedBox(width: 10), + TextButton.icon( + icon: const Icon(Icons.add, size: 18), onPressed: showEdit, label: Text(localizations.add)), + const SizedBox(width: 10), + TextButton.icon( + icon: const Icon(Icons.input_rounded, size: 18), + onPressed: import, + label: Text(localizations.import), + ), + const SizedBox(width: 10), + ]), + const SizedBox(height: 10), + Expanded(child: RequestMapList(list: data.rules)), + ])))); + } + + //导入js + Future import() async { + FilePickerResult? result = await FilePicker.platform.pickFiles(type: FileType.any); + if (result == null || result.files.isEmpty) { + return; + } + var file = result.files.single.xFile; + + try { + List json = jsonDecode(utf8.decode(await file.readAsBytes())); + + var manager = (await RequestMapManager.instance); + for (var item in json) { + var mapRule = RequestMapRule.fromJson(item); + var requestMapItem = RequestMapItem.fromJson(item['item']); + await manager.addRule(mapRule, requestMapItem); + } + + if (mounted) { + CustomToast.success(localizations.importSuccess).show(context); + } + setState(() {}); + } catch (e, t) { + logger.e('[RequestMap] import fail $file', error: e, stackTrace: t); + if (mounted) { + CustomToast.error("${localizations.importFailed} $e").show(context); + } + } + } + + /// 添加脚本 + Future showEdit() async { + Navigator.push(context, MaterialPageRoute(builder: (_) => const MobileRequestMapEdit())).then((value) { + if (value != null) { + setState(() {}); + } + }); + } +} + +/// 脚本列表 +class RequestMapList extends StatefulWidget { + final List list; + + const RequestMapList({super.key, required this.list}); + + @override + State createState() => _RequestMapListState(); +} + +class _RequestMapListState extends State { + Set selected = {}; + bool multiple = false; + bool changed = false; + + AppLocalizations get localizations => AppLocalizations.of(context)!; + + @override + void dispose() { + if (changed) { + _refreshConfig(force: true); + } + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + persistentFooterButtons: [multiple ? globalMenu() : const SizedBox()], + body: Container( + padding: const EdgeInsets.only(top: 10), + decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2))), + child: Scrollbar( + child: ListView(children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Container(width: 130, padding: const EdgeInsets.only(left: 10), child: Text(localizations.name)), + SizedBox(width: 50, child: Text(localizations.enable, textAlign: TextAlign.center)), + const VerticalDivider(), + const Expanded(child: Text("URL")), + SizedBox(width: 100, child: Text(localizations.action, textAlign: TextAlign.center)), + ], + ), + const Divider(thickness: 0.5), + Column(children: rows(widget.list)) + ])))); + } + + List rows(List list) { + var primaryColor = Theme.of(context).colorScheme.primary; + bool isEN = Localizations.localeOf(context) == const Locale.fromSubtags(languageCode: 'en'); + + return List.generate(list.length, (index) { + return InkWell( + highlightColor: Colors.transparent, + splashColor: Colors.transparent, + hoverColor: primaryColor.withOpacity(0.3), + onLongPress: () => showMenus(index), + onTap: () async { + if (multiple) { + setState(() { + if (!selected.add(index)) { + selected.remove(index); + } + }); + return; + } + showEdit(index); + }, + child: Container( + color: selected.contains(index) + ? primaryColor.withOpacity(0.6) + : index.isEven + ? Colors.grey.withOpacity(0.1) + : null, + height: 30, + padding: const EdgeInsets.all(5), + child: Row( + children: [ + SizedBox(width: 60, child: Text(list[index].name ?? '', style: const TextStyle(fontSize: 13))), + SizedBox( + width: 35, + child: Transform.scale( + scale: 0.6, + child: SwitchWidget( + value: list[index].enabled, + onChanged: (val) { + list[index].enabled = val; + _refreshConfig(); + }))), + const SizedBox(width: 20), + Expanded( + child: + Text(list[index].url, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13))), + const SizedBox(width: 3), + SizedBox( + width: 60, + child: Text(isEN ? list[index].type.name.camelCaseToSpaced() : list[index].type.label, + textAlign: TextAlign.center, style: const TextStyle(fontSize: 13))), + ], + ))); + }); + } + + Stack 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.toList()); + setState(() { + selected.clear(); + multiple = false; + }); + }, + icon: const Icon(Icons.share, size: 18), + label: Text(localizations.export, style: const TextStyle(fontSize: 14))), + TextButton.icon( + onPressed: () => remove(selected.toList()), + 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))), + ])))) + ]); + } + + //点击菜单 + void showMenus(int index) { + setState(() { + selected.add(index); + }); + + showModalBottomSheet( + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(10))), + context: context, + enableDrag: true, + builder: (ctx) { + return Wrap(alignment: WrapAlignment.center, children: [ + BottomSheetItem( + text: localizations.multiple, + onPressed: () { + setState(() => multiple = true); + }), + const Divider(thickness: 0.5, height: 5), + BottomSheetItem(text: localizations.edit, onPressed: () => showEdit(index)), + const Divider(thickness: 0.5, height: 5), + BottomSheetItem(text: localizations.export, onPressed: () => export([index])), + const Divider(thickness: 0.5, height: 5), + BottomSheetItem( + text: widget.list[index].enabled ? localizations.disabled : localizations.enable, + onPressed: () { + widget.list[index].enabled = !widget.list[index].enabled; + _refreshConfig(); + }), + const Divider(thickness: 0.5, height: 5), + BottomSheetItem( + text: localizations.delete, + onPressed: () async { + var manager = await RequestMapManager.instance; + await manager.deleteRule(index); + _refreshConfig(); + }), + ]); + }).then((value) { + if (multiple) { + return; + } + setState(() { + selected.remove(index); + }); + }); + } + + Future showEdit([int? index]) async { + final item = index == null ? null : await (await RequestMapManager.instance).getMapItem(widget.list[index]); + if (!mounted) { + return; + } + + showDialog( + barrierDismissible: false, + context: context, + builder: (_) => MobileRequestMapEdit(rule: index == null ? null : widget.list[index], item: item)) + .then((value) { + if (value != null) { + setState(() {}); + } + }); + } + + //导出 + Future export(List indexes) async { + if (indexes.isEmpty) return; + //文件名称 + String fileName = 'request_map.json'; + + var manager = await RequestMapManager.instance; + List json = []; + for (var idx in indexes) { + var item = widget.list[idx]; + var map = item.toJson(); + map.remove("itemPath"); + map['item'] = (await manager.getMapItem(item))?.toJson(); + json.add(map); + } + + RenderBox? box; + if (await Platforms.isIpad() && mounted) { + box = context.findRenderObject() as RenderBox?; + } + + final XFile file = XFile.fromData(utf8.encode(jsonEncode(json)), mimeType: 'config'); + ShareParams shareParams = ShareParams( + files: [file], + fileNameOverrides: [fileName], + sharePositionOrigin: box?.paintBounds, + ); + await SharePlus.instance.share(shareParams); + } + + void enableStatus(bool enable) { + for (var idx in selected) { + widget.list[idx].enabled = enable; + } + setState(() {}); + _refreshConfig(); + } + + Future remove(List indexes) async { + if (indexes.isEmpty) return; + showConfirmDialog(context, content: localizations.confirmContent, onConfirm: () async { + var manager = await RequestMapManager.instance; + for (var idx in indexes) { + await manager.deleteRule(idx); + } + + setState(() { + selected.clear(); + }); + _refreshConfig(force: true); + + if (mounted) FlutterToastr.show(localizations.deleteSuccess, context); + }); + } +} + +///请求重写规则添加对话框 +class MobileRequestMapEdit extends StatefulWidget { + final RequestMapRule? rule; + final RequestMapItem? item; + final String? url; + final String? title; + + const MobileRequestMapEdit({super.key, this.rule, this.item, this.url, this.title}); + + @override + State createState() { + return _RequestMapEditState(); + } +} + +class _RequestMapEditState extends State { + final mapLocalKey = GlobalKey(); + final mapScriptKey = GlobalKey(); + final ScrollController scrollController = ScrollController(); + + late RequestMapRule rule; + + late RequestMapType mapType; + late TextEditingController nameInput; + late TextEditingController urlInput; + + AppLocalizations get localizations => AppLocalizations.of(context)!; + + @override + void initState() { + super.initState(); + rule = widget.rule ?? RequestMapRule(url: widget.url ?? '', name: widget.title, type: RequestMapType.local); + mapType = rule.type; + nameInput = TextEditingController(text: rule.name); + urlInput = TextEditingController(text: rule.url); + } + + @override + void dispose() { + urlInput.dispose(); + nameInput.dispose(); + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + GlobalKey formKey = GlobalKey(); + bool isEN = Localizations.localeOf(context) == const Locale.fromSubtags(languageCode: 'en'); + + return Scaffold( + appBar: AppBar( + title: Row(children: [ + Text(localizations.requestRewriteRule, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + ]), + actions: [ + TextButton( + child: Text(localizations.save), + onPressed: () async { + if (!(formKey.currentState as FormState).validate()) { + FlutterToastr.show(localizations.cannotBeEmpty, context, position: FlutterToastr.center); + return; + } + + (formKey.currentState as FormState).save(); + rule.name = nameInput.text; + rule.url = urlInput.text; + rule.type = mapType; + RequestMapItem item; + if (mapType == RequestMapType.local) { + item = mapLocalKey.currentState!.getRequestMapItem(); + } else { + String? scriptCode = mapScriptKey.currentState?.getScriptCode(); + item = widget.item ?? RequestMapItem(); + item.script = scriptCode; + } + + var requestMapManager = await RequestMapManager.instance; + var index = requestMapManager.rules.indexOf(rule); + if (index >= 0) { + await requestMapManager.updateRule(rule, item); + } else { + await requestMapManager.addRule(rule, item); + } + + if (mounted) { + Navigator.of(this.context).pop(rule); + } + }) + ]), + body: Container( + padding: const EdgeInsets.all(15), + child: NestedScrollView( + controller: scrollController, + headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { + return [ + SliverToBoxAdapter( + child: Form( + key: formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + SizedBox(width: 55, child: Text('${localizations.enable}:')), + SwitchWidget(value: rule.enabled, onChanged: (val) => rule.enabled = val, scale: 0.8) + ]), + const SizedBox(height: 5), + textField('${localizations.name}:', nameInput, localizations.pleaseEnter), + const SizedBox(height: 5), + textField('URL:', urlInput, 'https://www.example.com/api/*', required: true), + const SizedBox(height: 5), + Row(children: [ + SizedBox(width: 60, child: Text('${localizations.action}:')), + SizedBox( + width: 150, + height: 33, + child: DropdownButtonFormField( + onSaved: (val) => rule.type = val!, + value: mapType, + decoration: InputDecoration( + errorStyle: const TextStyle(height: 0, fontSize: 0), + contentPadding: const EdgeInsets.only(left: 7, right: 7), + focusedBorder: focusedBorder(), + border: const OutlineInputBorder()), + items: RequestMapType.values + .map((e) => DropdownMenuItem( + value: e, + child: + Text(isEN ? e.name : e.label, style: const TextStyle(fontSize: 13)))) + .toList(), + onChanged: onChangeType, + )), + const SizedBox(width: 10), + ]), + const SizedBox(height: 10), + ]))) + ]; + }, + body: mapRule(), + ), + )); + } + + void onChangeType(RequestMapType? val) async { + if (mapType == val) return; + mapType = val!; + setState(() {}); + } + + Widget mapRule() { + if (mapType == RequestMapType.script) { + return MobileMapScript(key: mapScriptKey, script: widget.item?.script); + } + + return MobileMapLocal(scrollController: scrollController, key: mapLocalKey, item: widget.item); + } + + Widget textField(String label, TextEditingController controller, String hint, + {bool required = false, FormFieldSetter? onSaved}) { + return Row(children: [ + SizedBox(width: 60, child: Text(label)), + Expanded( + child: TextFormField( + controller: controller, + style: const TextStyle(fontSize: 14), + validator: (val) => val?.isNotEmpty == true || !required ? null : "", + onSaved: onSaved, + decoration: InputDecoration( + hintText: hint, + constraints: const BoxConstraints(minHeight: 38), + hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14), + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + errorStyle: const TextStyle(height: 0, fontSize: 0), + focusedBorder: focusedBorder(), + isDense: true, + border: const OutlineInputBorder()), + )) + ]); + } + + InputBorder focusedBorder() { + return OutlineInputBorder(borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)); + } +} diff --git a/lib/ui/mobile/setting/request_map/map_local.dart b/lib/ui/mobile/setting/request_map/map_local.dart new file mode 100644 index 0000000..1e12480 --- /dev/null +++ b/lib/ui/mobile/setting/request_map/map_local.dart @@ -0,0 +1,399 @@ +/* + * Copyright 2023 Hongen Wang All rights reserved. + * + * 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 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:proxypin/l10n/app_localizations.dart'; +import 'package:proxypin/network/components/manager/request_map_manager.dart'; +import 'package:proxypin/network/components/manager/rewrite_rule.dart'; +import 'package:proxypin/ui/component/state_component.dart'; + +import '../../../component/utils.dart'; + +/// 重写替换 +/// @author wanghongen +/// 2023/10/8 +class MobileMapLocal extends StatefulWidget { + final RequestMapItem? item; + final ScrollController? scrollController; + + const MobileMapLocal({super.key, this.item, this.scrollController}); + + @override + State createState() => MobileMapLocaleState(); +} + +class MobileMapLocaleState extends State { + final _headerKey = GlobalKey(); + final bodyTextController = TextEditingController(); + + RxString bodyType = RxString(ReplaceBodyType.text.name); + Rxn bodyFile = Rxn(); + TextEditingController statusCodeController = TextEditingController(text: '200'); + + late ScrollController? bodyScrollController; + + AppLocalizations get localizations => AppLocalizations.of(context)!; + + @override + initState() { + super.initState(); + initItem(widget.item); + bodyScrollController = trackingScroll(widget.scrollController); + } + + @override + dispose() { + bodyTextController.dispose(); + statusCodeController.dispose(); + bodyScrollController?.dispose(); + super.dispose(); + } + + ///初始化重写项 + void initItem(RequestMapItem? item) { + if (item == null) { + return; + } + statusCodeController.text = item.statusCode?.toString() ?? '200'; + bodyTextController.text = item.body ?? ''; + bodyType.value = item.bodyType ?? ReplaceBodyType.text.name; + } + + RequestMapItem getRequestMapItem() { + RequestMapItem item = widget.item ?? RequestMapItem(); + var headers = _headerKey.currentState?.getHeaders(); + item.statusCode = int.tryParse(statusCodeController.text) ?? 200; + item.headers = headers; + item.body = bodyTextController.text; + item.bodyType = item.bodyType ?? ReplaceBodyType.text.name; + if (item.bodyType == ReplaceBodyType.file.name) { + item.bodyFile = bodyFile.value; + } else { + item.bodyFile = null; + } + return item; + } + + @override + Widget build(BuildContext context) { + List tabs = [localizations.statusCode, localizations.responseHeader, localizations.responseBody]; + + return DefaultTabController( + length: tabs.length, + initialIndex: tabs.length - 1, + child: Scaffold( + appBar: tabBar(tabs), + body: TabBarView(children: [ + KeepAliveWrapper(child: statusCodeEdit()), + KeepAliveWrapper(child: headers()), + KeepAliveWrapper(child: body()) + ]), + )); + } + + //tabBar + TabBar tabBar(List tabs) { + return TabBar( + tabs: tabs + .map((label) => Tab( + height: 38, + child: Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + )) + .toList()); + } + + //body + Widget body() { + bool isEN = Localizations.localeOf(context) == const Locale.fromSubtags(languageCode: 'en'); + + return Obx(() => ListView(physics: const ClampingScrollPhysics(), children: [ + Row(mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ + const SizedBox(width: 5), + Text("${localizations.type}: "), + SizedBox( + width: 90, + child: DropdownButtonFormField( + value: bodyType.value, + focusColor: Colors.transparent, + itemHeight: 48, + decoration: const InputDecoration( + contentPadding: EdgeInsets.all(10), isDense: true, border: InputBorder.none), + items: ReplaceBodyType.values + .map((e) => DropdownMenuItem( + value: e.name, + child: Text(isEN ? e.name.toUpperCase() : e.label, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)))) + .toList(), + onChanged: (val) => bodyType.value = val ?? ReplaceBodyType.text.name)), + ]), + const SizedBox(height: 10), + if (bodyType.value == ReplaceBodyType.file.name) + fileBodyEdit() + else + TextFormField( + controller: bodyTextController, + scrollPhysics: const BouncingScrollPhysics(), + scrollController: bodyScrollController, + style: const TextStyle(fontSize: 14), + minLines: 18, + maxLines: 20, + decoration: decoration(localizations.replaceBodyWith, + hintText: '${localizations.example} {"code":"200","data":{}}')), + ])); + } + + Widget fileBodyEdit() { + //选择文件 删除 + return Obx(() => Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Expanded( + child: bodyFile.value == null + ? Container(height: 50) + : Container( + padding: const EdgeInsets.all(5), + foregroundDecoration: + BoxDecoration(border: Border.all(color: Theme.of(context).colorScheme.primary, width: 1)), + child: Text(bodyFile.value ?? ''))), + const SizedBox(width: 10), + FilledButton( + onPressed: () async { + FilePickerResult? result = await FilePicker.platform.pickFiles(); + String? path = result?.files.single.path; + + if (path == null) { + return; + } + bodyFile.value = path; + }, + child: Text(localizations.selectFile, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500))), + const SizedBox(width: 10), + FilledButton( + onPressed: () { + setState(() { + bodyFile.value = null; + }); + }, + child: Text(localizations.delete, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500))), + ])); + } + + //headers + Widget headers() { + return Headers(headers: widget.item?.headers, key: _headerKey); + } + + Widget textField(String label, dynamic value, String hint, {ValueChanged? onChanged}) { + return Row(children: [ + SizedBox(width: 80, child: Text(label)), + Expanded( + child: TextFormField( + initialValue: value, + onChanged: onChanged, + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14), + contentPadding: const EdgeInsets.all(10), + errorStyle: const TextStyle(height: 0, fontSize: 0), + focusedBorder: focusedBorder(), + isDense: true, + border: const OutlineInputBorder()), + )) + ]); + } + + Widget statusCodeEdit() { + return Container( + padding: const EdgeInsets.all(10), + child: Column(children: [ + Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Text(localizations.statusCode), + const SizedBox(width: 10), + SizedBox( + width: 100, + child: TextFormField( + controller: statusCodeController, + style: const TextStyle(fontSize: 14), + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(10), + focusedBorder: focusedBorder(), + isDense: true, + border: const OutlineInputBorder()), + )), + const SizedBox(width: 10), + ]) + ])); + } + + InputDecoration decoration(String label, {String? hintText}) { + Color color = Theme.of(context).colorScheme.primary; + // Color color = Colors.blueAccent; + return InputDecoration( + floatingLabelBehavior: FloatingLabelBehavior.always, + labelText: label, + hintStyle: TextStyle(color: Colors.grey.shade500), + hintText: hintText, + isDense: true, + border: OutlineInputBorder(borderSide: BorderSide(width: 0.8, color: color)), + enabledBorder: OutlineInputBorder(borderSide: BorderSide(width: 1.5, color: color)), + focusedBorder: OutlineInputBorder(borderSide: BorderSide(width: 2, color: color))); + } + + InputBorder focusedBorder() { + return OutlineInputBorder(borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)); + } +} + +///请求头 +class Headers extends StatefulWidget { + final Map? headers; + + const Headers({super.key, this.headers}); + + @override + State createState() { + return HeadersState(); + } +} + +class HeadersState extends State with AutomaticKeepAliveClientMixin { + final Map _headers = {}; + + @override + bool get wantKeepAlive => true; + + AppLocalizations get localizations => AppLocalizations.of(context)!; + + @override + void initState() { + super.initState(); + if (widget.headers == null) { + _headers[TextEditingController()] = TextEditingController(); + return; + } + + setHeaders(widget.headers); + } + + void setHeaders(Map? headers) { + _clear(); + headers?.forEach((name, value) { + _headers[TextEditingController(text: name)] = TextEditingController(text: value); + }); + _headers[TextEditingController()] = TextEditingController(); + } + + ///获取所有请求头 + Map getHeaders() { + var headers = {}; + _headers.forEach((name, value) { + if (name.text.isEmpty) { + return; + } + headers[name.text] = value.text; + }); + return headers; + } + + @override + dispose() { + _clear(); + super.dispose(); + } + + void _clear() { + _headers.forEach((key, value) { + key.dispose(); + value.dispose(); + }); + _headers.clear(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + + var list = _buildRows(); + + return Column(children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 10, bottom: 10), + child: ListView.separated( + shrinkWrap: true, + separatorBuilder: (context, index) => + index == list.length ? const SizedBox() : const Divider(thickness: 0.2), + itemBuilder: (context, index) => list[index], + itemCount: list.length))), + TextButton( + child: Text("${localizations.add}Header", textAlign: TextAlign.center), + onPressed: () { + setState(() { + _headers[TextEditingController()] = TextEditingController(); + }); + }, + ), + ]); + } + + List _buildRows() { + List list = []; + + _headers.forEach((key, val) { + list.add(_row( + _cell(key, isKey: true), + _cell(val), + Padding( + padding: const EdgeInsets.only(right: 15), + child: InkWell( + onTap: () { + setState(() { + _headers.remove(key); + }); + }, + child: const Icon(Icons.remove_circle_outline, size: 16))))); + }); + + return list; + } + + Widget _cell(TextEditingController val, {bool isKey = false}) { + return Container( + padding: const EdgeInsets.only(right: 5), + child: TextFormField( + style: TextStyle(fontSize: 12, fontWeight: isKey ? FontWeight.w500 : null), + controller: val, + minLines: 1, + maxLines: 3, + decoration: InputDecoration( + isDense: true, + border: const OutlineInputBorder(), + enabledBorder: OutlineInputBorder(borderSide: BorderSide(width: 0.5, color: Colors.grey)), + hintStyle: TextStyle(fontSize: 12, color: Colors.grey), + hintText: isKey ? "Key" : "Value"))); + } + + Widget _row(Widget key, Widget val, Widget? op) { + return Row(children: [ + Expanded(flex: 4, child: key), + const Text(": ", style: TextStyle(color: Colors.deepOrangeAccent)), + Expanded(flex: 6, child: val), + op ?? const SizedBox() + ]); + } +} diff --git a/lib/ui/mobile/setting/request_map/map_scipt.dart b/lib/ui/mobile/setting/request_map/map_scipt.dart new file mode 100644 index 0000000..d110e64 --- /dev/null +++ b/lib/ui/mobile/setting/request_map/map_scipt.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_code_editor/flutter_code_editor.dart'; +import 'package:flutter_highlight/themes/monokai-sublime.dart'; +import 'package:highlight/languages/javascript.dart'; +import 'package:proxypin/l10n/app_localizations.dart'; +import 'package:proxypin/network/components/manager/script_manager.dart'; + +class MobileMapScript extends StatefulWidget { + final String? script; + + const MobileMapScript({super.key, this.script}); + + @override + State createState() => MobileMapScriptState(); +} + +class MobileMapScriptState extends State { + static String template = """ +async function onRequest(context, request) { + console.log(request.url); + //use fetch API request + // var result = await fetch('https://www.baidu.com/'); + var response = { + statusCode: 200, + body: 'Hello, world!', + headers: { + 'Content-Type': 'text/plain', + 'X-My-Header': 'My-Value' + } + }; + return response; +} + """; + late CodeController script; + + AppLocalizations get localizations => AppLocalizations.of(context)!; + + String getScriptCode() { + return script.text; + } + + @override + void initState() { + super.initState(); + script = CodeController(language: javascript, text: widget.script ?? template); + } + + @override + void dispose() { + script.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + // height: double.infinity, + child: CodeTheme( + data: CodeThemeData(styles: monokaiSublimeTheme), + child: SingleChildScrollView( + child: CodeField( + textStyle: const TextStyle(fontSize: 13), + enableSuggestions: true, + gutterStyle: const GutterStyle(width: 50, margin: 0), + onTapOutside: (event) => FocusScope.of(context).unfocus(), + controller: script), + ))); + } +} diff --git a/lib/ui/mobile/setting/request_rewrite.dart b/lib/ui/mobile/setting/request_rewrite.dart index f9df569..2207eb5 100644 --- a/lib/ui/mobile/setting/request_rewrite.dart +++ b/lib/ui/mobile/setting/request_rewrite.dart @@ -96,7 +96,7 @@ class _MobileRequestRewriteState extends State { } //导入 - import() async { + Future import() async { FilePickerResult? result = await FilePicker.platform.pickFiles(type: FileType.any); if (result == null || result.files.isEmpty) { return; @@ -194,7 +194,7 @@ class _RequestRuleListState extends State { )))); } - globalMenu() { + Stack globalMenu() { return Stack(children: [ Container( height: 50, @@ -238,7 +238,7 @@ class _RequestRuleListState extends State { List rows(List list) { var primaryColor = Theme.of(context).colorScheme.primary; - bool isCN = Localizations.localeOf(context) == const Locale.fromSubtags(languageCode: 'zh'); + bool isEN = Localizations.localeOf(context) == const Locale.fromSubtags(languageCode: 'en'); return List.generate(list.length, (index) { return InkWell( highlightColor: Colors.transparent, @@ -284,14 +284,14 @@ class _RequestRuleListState extends State { const SizedBox(width: 3), SizedBox( width: 60, - child: Text(isCN ? list[index].type.label : list[index].type.name.camelCaseToSpaced(), + child: Text(isEN ? list[index].type.name.camelCaseToSpaced() : list[index].type.label, textAlign: TextAlign.center, style: const TextStyle(fontSize: 13))), ], ))); }); } - showEdit(int index) async { + Future showEdit(int index) async { var rule = widget.requestRewrites.rules[index]; var rewriteItems = await widget.requestRewrites.getRewriteItems(rule); if (!mounted) return; @@ -306,7 +306,7 @@ class _RequestRuleListState extends State { } //点击菜单 - showMenus(int index) { + void showMenus(int index) { setState(() { selected.add(index); }); @@ -382,7 +382,8 @@ class _RequestRuleListState extends State { } final XFile file = XFile.fromData(utf8.encode(jsonEncode(list)), mimeType: 'config'); - await Share.shareXFiles([file], fileNameOverrides: [fileName], sharePositionOrigin: box?.paintBounds); + await SharePlus.instance + .share(ShareParams(files: [file], fileNameOverrides: [fileName], sharePositionOrigin: box?.paintBounds)); } //删除 diff --git a/lib/ui/mobile/setting/rewrite/rewrite_replace.dart b/lib/ui/mobile/setting/rewrite/rewrite_replace.dart index 68656f1..d8219ea 100644 --- a/lib/ui/mobile/setting/rewrite/rewrite_replace.dart +++ b/lib/ui/mobile/setting/rewrite/rewrite_replace.dart @@ -62,7 +62,7 @@ class RewriteReplaceState extends State { } ///初始化重写项 - initItems(RuleType ruleType, List? items) { + void initItems(RuleType ruleType, List? items) { this.items.clear(); this.ruleType = ruleType; if (ruleType == RuleType.redirect) { @@ -85,7 +85,7 @@ class RewriteReplaceState extends State { } } - _initRewriteItem(List? items, RewriteType type, {bool enabled = false}) { + void _initRewriteItem(List? items, RewriteType type, {bool enabled = false}) { var item = items?.firstWhereOrNull((it) => it.type == type); RewriteItem rewriteItem = RewriteItem(type, item?.enabled ?? enabled, values: item?.values); this.items.add(rewriteItem); @@ -450,7 +450,7 @@ class HeadersState extends State with AutomaticKeepAliveClientMixin { setHeaders(widget.headers); } - setHeaders(Map? headers) { + void setHeaders(Map? headers) { _clear(); headers?.forEach((name, value) { _headers[TextEditingController(text: name)] = TextEditingController(text: value); diff --git a/lib/ui/mobile/setting/script.dart b/lib/ui/mobile/setting/script.dart index 6acd092..7e18191 100644 --- a/lib/ui/mobile/setting/script.dart +++ b/lib/ui/mobile/setting/script.dart @@ -377,8 +377,9 @@ class ScriptEdit extends StatefulWidget { final ScriptItem? scriptItem; final String? script; final String? url; + final String? title; - const ScriptEdit({super.key, this.scriptItem, this.script, this.url}); + const ScriptEdit({super.key, this.scriptItem, this.script, this.url, this.title}); @override State createState() => _ScriptEditState(); @@ -395,7 +396,7 @@ class _ScriptEditState extends State { void initState() { super.initState(); script = CodeController(language: javascript, text: widget.script ?? ScriptManager.template); - nameController = TextEditingController(text: widget.scriptItem?.name); + nameController = TextEditingController(text: widget.scriptItem?.name ?? widget.title); urlController = TextEditingController(text: widget.scriptItem?.url ?? widget.url); }