/* * 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 'dart:convert'; import 'dart:io'; import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_toastr/flutter_toastr.dart'; import 'package:proxypin/l10n/app_localizations.dart'; import 'package:proxypin/network/components/manager/request_rewrite_manager.dart'; import 'package:proxypin/network/components/manager/rewrite_rule.dart'; import 'package:proxypin/network/http/http.dart'; import 'package:proxypin/network/util/logger.dart'; import 'package:proxypin/ui/component/multi_window.dart'; import 'package:proxypin/ui/component/utils.dart'; import 'package:proxypin/ui/component/widgets.dart'; import 'package:proxypin/ui/desktop/setting/rewrite/rewrite_replace.dart'; import 'package:proxypin/ui/desktop/setting/rewrite/rewrite_update.dart'; import 'package:proxypin/utils/lang.dart'; import '../../component/http_method_popup.dart'; /// @author wanghongen /// 2023/10/8 class RequestRewriteWidget extends StatefulWidget { final int windowId; final RequestRewriteManager requestRewrites; const RequestRewriteWidget({super.key, required this.windowId, required this.requestRewrites}); @override State createState() { return RequestRewriteState(); } } class RequestRewriteState extends State { late ValueNotifier enableNotifier; AppLocalizations get localizations => AppLocalizations.of(context)!; @override void initState() { super.initState(); HardwareKeyboard.instance.addHandler(onKeyEvent); enableNotifier = ValueNotifier(widget.requestRewrites.enabled == true); } @override void dispose() { HardwareKeyboard.instance.removeHandler(onKeyEvent); super.dispose(); } bool onKeyEvent(KeyEvent event) { if (HardwareKeyboard.instance.isLogicalKeyPressed(LogicalKeyboardKey.escape) && Navigator.canPop(context)) { Navigator.maybePop(context); return true; } if ((HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) && event.logicalKey == LogicalKeyboardKey.keyW) { if (Navigator.canPop(context)) { Navigator.pop(context); return true; } HardwareKeyboard.instance.removeHandler(onKeyEvent); WindowController.fromWindowId(widget.windowId).close(); return true; } return false; } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Theme.of(context).dialogBackgroundColor, appBar: AppBar( title: Text(localizations.requestRewrite, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), toolbarHeight: 34, centerTitle: true), body: Padding( padding: const EdgeInsets.only(left: 15, right: 10), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ SizedBox( width: 280, child: ValueListenableBuilder( valueListenable: enableNotifier, builder: (_, bool v, __) { return Transform.scale( scale: 0.8, child: SwitchListTile( contentPadding: const EdgeInsets.only(left: 2), title: Text(localizations.requestRewriteEnable), value: enableNotifier.value, onChanged: (value) { enableNotifier.value = value; MultiWindow.invokeRefreshRewrite(Operation.enabled, enabled: value); })); })), const SizedBox(width: 10), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ IconButton( onPressed: refresh, icon: const Icon(Icons.refresh, color: Colors.blue), tooltip: localizations.refresh), const SizedBox(width: 10), TextButton.icon( icon: const Icon(Icons.add, size: 18), label: Text(localizations.add), onPressed: add, ), const SizedBox(width: 5), TextButton.icon( icon: const Icon(Icons.input_rounded, size: 18), onPressed: import, label: Text(localizations.import), ) ], )), const SizedBox(width: 15) ]), const SizedBox(height: 10), RequestRuleList(widget.requestRewrites, windowId: widget.windowId), ]))); } //刷新 void refresh() async { await widget.requestRewrites.reloadRequestRewrite(); setState(() {}); } //导入js Future import() async { String? path; if (Platform.isMacOS) { path = await DesktopMultiWindow.invokeMethod(0, "pickFiles", { "allowedExtensions": ['config', 'json'] }); WindowController.fromWindowId(widget.windowId).show(); } else { FilePickerResult? result = await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['config', 'json']); path = result?.files.single.path; } if (path == null) { return; } try { List json = jsonDecode(await File(path).readAsString()); for (var item in json) { var rule = RequestRewriteRule.formJson(item); var items = (item['items'] as List).map((e) => RewriteItem.fromJson(e)).toList(); widget.requestRewrites.addRule(rule, items); await MultiWindow.invokeRefreshRewrite(Operation.add, rule: rule, items: items); } if (mounted) { FlutterToastr.show(localizations.importSuccess, context); } setState(() {}); } catch (e, t) { logger.e('导入失败 $path', error: e, stackTrace: t); if (mounted) { FlutterToastr.show("${localizations.importFailed} $e", context); } } } void add() { showDialog( context: context, barrierDismissible: false, builder: (BuildContext context) => RewriteRuleEdit(windowId: widget.windowId)).then((value) { if (value != null) setState(() {}); }); } } ///请求重写规则列表 class RequestRuleList extends StatefulWidget { final int windowId; final RequestRewriteManager requestRewrites; const RequestRuleList(this.requestRewrites, {super.key, required this.windowId}); @override State createState() => _RequestRuleListState(); } class _RequestRuleListState extends State { Map selected = {}; late List rules; bool isPressed = false; Offset? lastPressPosition; AppLocalizations get localizations => AppLocalizations.of(context)!; @override initState() { super.initState(); rules = widget.requestRewrites.rules; } @override Widget build(BuildContext context) { return GestureDetector( onSecondaryTap: () { if (lastPressPosition == null) { return; } showGlobalMenu(lastPressPosition!); }, onTapDown: (details) { if (selected.isEmpty) { return; } if (HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) { return; } setState(() { selected.clear(); }); }, child: Listener( onPointerUp: (event) => isPressed = false, onPointerDown: (event) { lastPressPosition = event.localPosition; if (event.buttons == kPrimaryMouseButton) { isPressed = true; } }, child: Container( padding: const EdgeInsets.only(top: 10), constraints: const BoxConstraints(maxHeight: 600, minHeight: 550), decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2))), child: SingleChildScrollView( child: Column(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.requestRewrites.rules)) ]))))); } void enableStatus(bool enable) { if (selected.isEmpty) return; selected.forEach((key, value) { if (rules[key].enabled == enable) return; rules[key].enabled = enable; MultiWindow.invokeRefreshRewrite(Operation.update, index: key, rule: rules[key]); }); setState(() {}); } void showGlobalMenu(Offset offset) { showContextMenu(context, offset, items: [ PopupMenuItem(height: 35, child: Text(localizations.newBuilt), onTap: () => showEdit()), PopupMenuItem(height: 35, child: Text(localizations.export), onTap: () => export(selected.keys.toList())), const PopupMenuDivider(), PopupMenuItem(height: 35, child: Text(localizations.enableSelect), onTap: () => enableStatus(true)), PopupMenuItem(height: 35, child: Text(localizations.disableSelect), onTap: () => enableStatus(false)), const PopupMenuDivider(), PopupMenuItem( height: 35, child: Text(localizations.deleteSelect), onTap: () => removeRewrite(selected.keys.toList())), ]); } 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), onSecondaryTapDown: (details) => showMenus(details, index), onDoubleTap: () => showEdit(index), onHover: (hover) { if (isPressed && selected[index] != true) { setState(() { selected[index] = true; }); } }, onTap: () { if (HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) { setState(() { selected[index] = !(selected[index] ?? false); }); return; } if (selected.isEmpty) { return; } setState(() { selected.clear(); }); }, child: Container( color: selected[index] == true ? primaryColor.withOpacity(0.6) : index.isEven ? Colors.grey.withOpacity(0.1) : null, height: 30, padding: const EdgeInsets.all(5), child: Row( children: [ SizedBox(width: 130, child: Text(list[index].name ?? '', style: const TextStyle(fontSize: 13))), SizedBox( width: 40, child: SwitchWidget( scale: 0.6, value: list[index].enabled, onChanged: (val) { list[index].enabled = val; MultiWindow.invokeRefreshRewrite(Operation.update, index: index, rule: list[index]); })), const SizedBox(width: 20), Expanded( child: Text(list[index].url, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13))), SizedBox( width: 100, child: Text(isEN ? list[index].type.name.camelCaseToSpaced() : list[index].type.label, textAlign: TextAlign.center, style: const TextStyle(fontSize: 13))), ], ))); }); } //导出 Future export(List indexes) async { if (indexes.isEmpty) return; String fileName = 'proxypin-rewrites.config'; String? path; if (Platform.isMacOS) { path = await DesktopMultiWindow.invokeMethod(0, "saveFile", {"fileName": fileName}); WindowController.fromWindowId(widget.windowId).show(); } else { path = await FilePicker.platform.saveFile(fileName: fileName); } if (path == null) { return; } var list = []; for (var index in indexes) { var rule = widget.requestRewrites.rules[index]; var json = rule.toJson(); json.remove("rewritePath"); json['items'] = await widget.requestRewrites.getRewriteItems(rule); list.add(json); } await File(path).writeAsBytes(utf8.encode(jsonEncode(list))); if (mounted) FlutterToastr.show(localizations.exportSuccess, context); } //删除 Future removeRewrite(List indexes) async { if (indexes.isEmpty) return; return showConfirmDialog(context, content: localizations.requestRewriteDeleteConfirm(indexes.length), onConfirm: () async { var list = indexes.toList(); list.sort((a, b) => b.compareTo(a)); for (var value in list) { await widget.requestRewrites.removeIndex([value]); MultiWindow.invokeRefreshRewrite(Operation.delete, index: value); } setState(() { selected.clear(); }); if (mounted) FlutterToastr.show(localizations.deleteSuccess, context); }); } Future showEdit([int? index]) async { RequestRewriteRule? rule; List? rewriteItems; if (index != null) { rule = widget.requestRewrites.rules[index]; rewriteItems = await widget.requestRewrites.getRewriteItems(rule); } if (!mounted) return; showDialog( context: context, barrierDismissible: false, builder: (BuildContext context) { return RewriteRuleEdit(rule: rule, items: rewriteItems, windowId: widget.windowId); }).then((value) { if (value != null) { setState(() {}); } }); } //点击菜单 void showMenus(TapDownDetails details, int index) { if (selected.length > 1) { showGlobalMenu(details.globalPosition); return; } setState(() { selected[index] = true; }); showContextMenu(context, details.globalPosition, items: [ PopupMenuItem(height: 35, child: Text(localizations.edit), onTap: () => showEdit(index)), PopupMenuItem(height: 35, onTap: () => export([index]), child: Text(localizations.export)), PopupMenuItem( height: 35, child: rules[index].enabled ? Text(localizations.disabled) : Text(localizations.enable), onTap: () { rules[index].enabled = !rules[index].enabled; MultiWindow.invokeRefreshRewrite(Operation.update, index: index, rule: rules[index]); }), const PopupMenuDivider(), PopupMenuItem( height: 35, child: Text(localizations.delete), onTap: () async { await widget.requestRewrites.removeIndex([index]); MultiWindow.invokeRefreshRewrite(Operation.delete, index: index); // setState(() {}); }) ]).then((value) { setState(() { selected.remove(index); }); }); } } ///请求重写规则添加对话框 class RewriteRuleEdit extends StatefulWidget { final RequestRewriteRule? rule; final List? items; final HttpRequest? request; final int? windowId; const RewriteRuleEdit({super.key, this.rule, this.items, this.windowId, this.request}); @override State createState() { return _RewriteRuleEditState(); } } class _RewriteRuleEditState extends State { final rewriteReplaceKey = GlobalKey(); final rewriteUpdateKey = GlobalKey(); late RequestRewriteRule rule; List? items; late RuleType ruleType; late TextEditingController nameInput; late TextEditingController urlInput; AppLocalizations get localizations => AppLocalizations.of(context)!; @override void initState() { super.initState(); rule = widget.rule ?? RequestRewriteRule(url: '', type: RuleType.responseReplace); items = widget.items; ruleType = rule.type; nameInput = TextEditingController(text: rule.name); urlInput = TextEditingController(text: rule.url); if (items == null && widget.request != null) { items = fromRequestItems(widget.request!, ruleType); } } @override void dispose() { urlInput.dispose(); nameInput.dispose(); super.dispose(); } @override Widget build(BuildContext context) { GlobalKey formKey = GlobalKey(); bool isCN = Localizations.localeOf(context) == const Locale.fromSubtags(languageCode: 'zh'); return AlertDialog( scrollable: true, titlePadding: const EdgeInsets.only(top: 10, left: 20), actionsPadding: const EdgeInsets.only(right: 15, bottom: 15), contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 5), title: Row(children: [ Text(localizations.requestRewriteRule, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), const SizedBox(width: 20), Text.rich(TextSpan( text: localizations.useGuide, style: const TextStyle(color: Colors.blue, fontSize: 14), recognizer: TapGestureRecognizer() ..onTap = () => DesktopMultiWindow.invokeMethod( 0, "launchUrl", isCN ? 'https://gitee.com/wanghongenpin/proxypin/wikis/%E8%AF%B7%E6%B1%82%E9%87%8D%E5%86%99' : 'https://github.com/wanghongenpin/proxypin/wiki/Request-Rewrite'))), ]), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.0)), content: Container( width: 550, constraints: const BoxConstraints(minHeight: 200, maxHeight: 560), 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: 10), // URL input with Method as prefix (method shown before the URL field) Row(children: [ SizedBox(width: 60, child: Text('URL:')), Expanded( child: TextFormField( controller: urlInput, style: const TextStyle(fontSize: 14), validator: (val) => val?.isNotEmpty == true ? null : "", decoration: InputDecoration( hintText: 'https://www.example.com/api/*', hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14), contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 10), errorStyle: const TextStyle(height: 0, fontSize: 0), focusedBorder: focusedBorder(), isDense: true, border: const OutlineInputBorder(), prefixIcon: Padding( padding: const EdgeInsets.only(left: 6, right: 6), child: MethodPopupMenu( value: rule.method, showSeparator: true, onChanged: (val) { setState(() { rule.method = val; }); }, ), ), ), ), ), ]), const SizedBox(height: 10), Row(children: [ SizedBox(width: 60, child: Text('${localizations.action}:')), SizedBox( width: 150, height: 36, child: DropdownButtonFormField( onSaved: (val) => rule.type = val!, value: ruleType, decoration: InputDecoration( errorStyle: const TextStyle(height: 0, fontSize: 0), contentPadding: const EdgeInsets.only(left: 7, right: 7), focusedBorder: focusedBorder(), border: const OutlineInputBorder()), items: RuleType.values .map((e) => DropdownMenuItem( value: e, child: Text(isCN ? e.label : e.name, style: const TextStyle(fontSize: 14)))) .toList(), onChanged: onChangeType, )), const SizedBox(width: 10), ]), const SizedBox(height: 10), rewriteRule(), ]))), actions: [ ElevatedButton(child: Text(localizations.close), onPressed: () => Navigator.of(context).pop()), FilledButton( 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; // method already set on change items = rewriteReplaceKey.currentState?.getItems() ?? rewriteUpdateKey.currentState?.getItems(); var requestRewrites = await RequestRewriteManager.instance; requestRewrites.rewriteItemsCache[rule] = items!; var index = requestRewrites.rules.indexOf(rule); if (index >= 0) { //存在 更新重写 MultiWindow.invokeRefreshRewrite(Operation.update, index: index, rule: rule, items: items); } else { //添加 if (widget.windowId != null) { requestRewrites.rules.add(rule); } MultiWindow.invokeRefreshRewrite(Operation.add, rule: rule, items: items); } if (mounted) { Navigator.of(this.context).pop(rule); } }) ]); } void onChangeType(RuleType? val) async { if (ruleType == val) return; ruleType = val!; items = []; if (ruleType == widget.rule?.type) { items = widget.items; } else if (widget.request != null) { items?.addAll(fromRequestItems(widget.request!, ruleType)); } setState(() { rewriteReplaceKey.currentState?.initItems(ruleType, items); rewriteUpdateKey.currentState?.initItems(ruleType, items); }); } static List fromRequestItems(HttpRequest request, RuleType ruleType) { if (ruleType == RuleType.requestReplace) { //请求替换 return RewriteItem.fromRequest(request); } else if (ruleType == RuleType.responseReplace && request.response != null) { //响应替换 return RewriteItem.fromResponse(request.response!); } return []; } Widget rewriteRule() { if (ruleType == RuleType.requestUpdate || ruleType == RuleType.responseUpdate) { return DesktopRewriteUpdate(key: rewriteUpdateKey, items: items, ruleType: ruleType, request: widget.request); } return DesktopRewriteReplace(key: rewriteReplaceKey, items: items, ruleType: ruleType, windowId: widget.windowId); } 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, hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14), contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), 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)); } }