From f418a205688151ab716d5466a793743e2636defd Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Mon, 16 Feb 2026 21:57:09 +0800 Subject: [PATCH] add request breakpoint functionality (#669)(#660)(#386) --- lib/l10n/app_en.arb | 4 +- lib/l10n/app_localizations.dart | 24 +- lib/l10n/app_localizations_en.dart | 12 +- lib/l10n/app_localizations_zh.dart | 24 +- lib/l10n/app_zh.arb | 5 +- lib/l10n/app_zh_Hant.arb | 2 + lib/network/bin/server.dart | 2 + .../manager/request_breakpoint_manager.dart | 131 +++++ .../components/request_breakpoint.dart | 40 ++ lib/ui/component/multi_window.dart | 14 + lib/ui/desktop/left_menus/favorite.dart | 2 +- lib/ui/desktop/left_menus/navigation.dart | 2 +- lib/ui/desktop/request/request.dart | 3 +- .../desktop/setting/request_breakpoint.dart | 503 ++++++++++++++++++ lib/ui/desktop/setting/setting.dart | 6 +- 15 files changed, 752 insertions(+), 22 deletions(-) create mode 100644 lib/network/components/manager/request_breakpoint_manager.dart create mode 100644 lib/network/components/request_breakpoint.dart create mode 100644 lib/ui/desktop/setting/request_breakpoint.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 594b3a3..bfc0753 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,4 +1,7 @@ { + "breakpoint": "Breakpoint", + "breakpointRule": "Breakpoint Rule", + "name": "Name", "requests": "Requests", "favorites": "Favorites", "history": "History", @@ -176,7 +179,6 @@ "deleteFavorite": "Delete Favorite", "emptyFavorite": "Empty Favorite", "deleteFavoriteSuccess": "Favorite deleted", - "name": "Name", "historyRecord": "History", "historyCacheTime": "Cache Time", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 52bf712..8865a22 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -96,6 +96,24 @@ abstract class AppLocalizations { Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant') ]; + /// No description provided for @breakpoint. + /// + /// In en, this message translates to: + /// **'Breakpoint'** + String get breakpoint; + + /// No description provided for @breakpointRule. + /// + /// In en, this message translates to: + /// **'Breakpoint Rule'** + String get breakpointRule; + + /// No description provided for @name. + /// + /// In en, this message translates to: + /// **'Name'** + String get name; + /// No description provided for @requests. /// /// In en, this message translates to: @@ -1104,12 +1122,6 @@ abstract class AppLocalizations { /// **'Favorite deleted'** String get deleteFavoriteSuccess; - /// No description provided for @name. - /// - /// In en, this message translates to: - /// **'Name'** - String get name; - /// No description provided for @historyRecord. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ba8cfbd..e6e3d45 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -8,6 +8,15 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String get breakpoint => 'Breakpoint'; + + @override + String get breakpointRule => 'Breakpoint Rule'; + + @override + String get name => 'Name'; + @override String get requests => 'Requests'; @@ -517,9 +526,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get deleteFavoriteSuccess => 'Favorite deleted'; - @override - String get name => 'Name'; - @override String get historyRecord => 'History'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 7f1693e..8141a7c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -8,6 +8,15 @@ import 'app_localizations.dart'; class AppLocalizationsZh extends AppLocalizations { AppLocalizationsZh([String locale = 'zh']) : super(locale); + @override + String get breakpoint => '断点'; + + @override + String get breakpointRule => '断点规则'; + + @override + String get name => '名称'; + @override String get requests => '抓包'; @@ -516,9 +525,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get deleteFavoriteSuccess => '已删除收藏'; - @override - String get name => '名称'; - @override String get historyRecord => '历史记录'; @@ -1063,6 +1069,15 @@ class AppLocalizationsZh extends AppLocalizations { class AppLocalizationsZhHant extends AppLocalizationsZh { AppLocalizationsZhHant() : super('zh_Hant'); + @override + String get breakpoint => '斷點'; + + @override + String get breakpointRule => '斷點規則'; + + @override + String get name => '名稱'; + @override String get requests => '抓包'; @@ -1571,9 +1586,6 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get deleteFavoriteSuccess => '已刪除收藏'; - @override - String get name => '名稱'; - @override String get historyRecord => '歷史記錄'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 99c5c76..a7d8937 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,4 +1,8 @@ { + + "breakpoint": "断点", + "breakpointRule": "断点规则", + "name": "名称", "requests": "抓包", "favorites": "收藏", "history": "历史", @@ -176,7 +180,6 @@ "deleteFavorite": "删除收藏", "emptyFavorite": "暂无收藏", "deleteFavoriteSuccess": "已删除收藏", - "name": "名称", "historyRecord": "历史记录", "historyManualSave": "手动保存", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 19ea2c0..b508797 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1,4 +1,6 @@ { + "breakpoint": "斷點", + "breakpointRule": "斷點規則", "requests": "抓包", "favorites": "收藏", "history": "歷史", diff --git a/lib/network/bin/server.dart b/lib/network/bin/server.dart index 004aced..5918d0a 100644 --- a/lib/network/bin/server.dart +++ b/lib/network/bin/server.dart @@ -34,6 +34,7 @@ import '../channel/network.dart'; import '../util/logger.dart'; import '../util/system_proxy.dart'; import 'listener.dart'; +import 'package:proxypin/network/components/request_breakpoint.dart'; Future main() async { var configuration = await Configuration.instance; @@ -86,6 +87,7 @@ class ProxyServer { RequestRewriteInterceptor.instance, ScriptInterceptor(), RequestBlockInterceptor(), + RequestBreakpointInterceptor.instance, // Register the interceptor ReportServerInterceptor() ]; diff --git a/lib/network/components/manager/request_breakpoint_manager.dart b/lib/network/components/manager/request_breakpoint_manager.dart new file mode 100644 index 0000000..aabe39c --- /dev/null +++ b/lib/network/components/manager/request_breakpoint_manager.dart @@ -0,0 +1,131 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:proxypin/network/http/http.dart'; +import 'package:proxypin/network/util/logger.dart'; + + +class RequestBreakpointRule { + bool enabled; + String? name; + String url; + + bool interceptRequest; + bool interceptResponse; + + // Optional HTTP method matching; null means match any method + HttpMethod? method; + + RequestBreakpointRule({ + this.enabled = true, + this.name, + required this.url, + this.interceptRequest = true, + this.interceptResponse = true, + this.method, + }); + + bool match(String url, {HttpMethod? method}) { + if (!enabled) return false; + if (this.method != null && method != null && this.method != method) return false; + return url.contains(this.url); + } + + factory RequestBreakpointRule.fromJson(Map map) { + HttpMethod? method; + try { + if (map['method'] != null) { + method = HttpMethod.valueOf(map['method']); + } + } catch (e) { + logger.e('Failed to parse HTTP method from request intercept rule', error: e); + } + + return RequestBreakpointRule( + enabled: map['enabled'] ?? true, + name: map['name'], + url: map['url'] ?? '', + interceptRequest: map['interceptRequest'] ?? true, + interceptResponse: map['interceptResponse'] ?? true, + method: method, + ); + } + + Map toJson() { + return { + 'enabled': enabled, + 'name': name, + 'url': url, + 'interceptRequest': interceptRequest, + 'interceptResponse': interceptResponse, + 'method': method?.name, + }; + } +} + +class RequestBreakpointManager { + static RequestBreakpointManager? _instance; + + RequestBreakpointManager._(); + + static Future get instance async { + if (_instance == null) { + _instance = RequestBreakpointManager._(); + await _instance!.load(); + } + return _instance!; + } + + bool enabled = true; + List list = []; + + static Future homePath() async { + if (Platform.isMacOS) { + return await DesktopMultiWindow.invokeMethod(0, "getApplicationSupportDirectory"); + } + return await getApplicationSupportDirectory().then((it) => it.path); + } + + Future load() async { + try { + var home = await homePath(); + var file = File('$home${Platform.pathSeparator}request_intercept.json'); + if (await file.exists()) { + var json = jsonDecode(await file.readAsString()); + enabled = json['enabled'] ?? false; + list = (json['list'] as List? ?? []).map((e) => RequestBreakpointRule.fromJson(e)).toList(); + } + } catch (e) { + logger.e('Failed to load request intercept config', error: e); + } + } + + Future save() async { + try { + var home = await homePath(); + var file = File('$home${Platform.pathSeparator}request_intercept.json'); + if (!await file.exists()) { + await file.create(recursive: true); + } + var json = { + 'enabled': enabled, + 'list': list.map((e) => e.toJson()).toList(), + }; + await file.writeAsString(jsonEncode(json)); + } catch (e) { + logger.e('Failed to save request intercept config', error: e); + } + } + + void add(RequestBreakpointRule rule) { + list.add(rule); + save(); + } + + void remove(RequestBreakpointRule rule) { + list.remove(rule); + save(); + } +} diff --git a/lib/network/components/request_breakpoint.dart b/lib/network/components/request_breakpoint.dart new file mode 100644 index 0000000..0c3a413 --- /dev/null +++ b/lib/network/components/request_breakpoint.dart @@ -0,0 +1,40 @@ +import 'package:proxypin/network/components/interceptor.dart'; +import 'package:proxypin/network/components/manager/request_breakpoint_manager.dart'; +import 'package:proxypin/network/http/http.dart'; + +class RequestBreakpointInterceptor extends Interceptor { + static RequestBreakpointInterceptor instance = RequestBreakpointInterceptor._(); + + final manager = RequestBreakpointManager.instance; + + RequestBreakpointInterceptor._(); + + @override + Future onRequest(HttpRequest request) async { + RequestBreakpointManager requestBreakpointManager = await manager; + if (!requestBreakpointManager.enabled) return request; + + var url = request.requestUrl; + for (var rule in requestBreakpointManager.list) { + if (rule.match(url, method: request.method) && rule.interceptRequest) { + // TODO: Breakpoint logic here (suspend request) + } + } + return request; + } + + @override + Future onResponse(HttpRequest request, HttpResponse response) async { + RequestBreakpointManager requestBreakpointManager = await manager; + if (!requestBreakpointManager.enabled) return response; + + var url = request.requestUrl; + for (var rule in requestBreakpointManager.list) { + if (rule.match(url, method: request.method) && rule.interceptResponse) { + // TODO: Breakpoint logic here (suspend response) + } + } + return response; + } +} + diff --git a/lib/ui/component/multi_window.dart b/lib/ui/component/multi_window.dart index f8c3bd1..6a8215c 100644 --- a/lib/ui/component/multi_window.dart +++ b/lib/ui/component/multi_window.dart @@ -24,6 +24,7 @@ import 'package:proxypin/l10n/app_localizations.dart'; import 'package:path_provider/path_provider.dart'; import 'package:proxypin/network/bin/server.dart'; import 'package:proxypin/network/components/manager/request_crypto_manager.dart'; +import 'package:proxypin/network/components/manager/request_breakpoint_manager.dart'; import 'package:proxypin/network/components/manager/request_map_manager.dart'; import 'package:proxypin/network/components/manager/request_rewrite_manager.dart'; import 'package:proxypin/network/components/manager/rewrite_rule.dart'; @@ -42,6 +43,7 @@ import 'package:proxypin/utils/platform.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:window_manager/window_manager.dart'; +import '../desktop/setting/request_breakpoint.dart'; import '../desktop/setting/request_crypto.dart'; import '../desktop/setting/request_map.dart'; import '../toolbox/cert_hash.dart'; @@ -107,6 +109,11 @@ Widget multiWindow(int windowId, Map argument) { return RequestMapPage(windowId: windowId); } + // 请求拦截 + if (argument['name'] == 'RequestBreakpointPage') { + return RequestBreakpointPage(windowId: windowId); + } + if (argument['name'] == 'QrCodePage') { return QrCodePage(windowId: windowId); } @@ -264,6 +271,13 @@ void registerMethodHandler() { return 'done'; } + if (call.method == 'refreshRequestBreakpoint') { + await RequestBreakpointManager.instance.then((value) { + return value.load(); + }); + return 'done'; + } + if (call.method == 'pickFiles') { var extensions = call.arguments != null ? call.arguments['allowedExtensions'] : null; FilePickerResult? result = await FilePicker.platform.pickFiles( diff --git a/lib/ui/desktop/left_menus/favorite.dart b/lib/ui/desktop/left_menus/favorite.dart index 415fde9..fe2d954 100644 --- a/lib/ui/desktop/left_menus/favorite.dart +++ b/lib/ui/desktop/left_menus/favorite.dart @@ -219,7 +219,7 @@ class _FavoriteItemState extends State<_FavoriteItem> { HttpClients.proxyRequest(httpRequest, proxyInfo: proxyInfo); if (mounted) { - CustomToast.success(localizations.reSendRequest).show(context); + FlutterToastr.show(localizations.reSendRequest, context); } } diff --git a/lib/ui/desktop/left_menus/navigation.dart b/lib/ui/desktop/left_menus/navigation.dart index 9040b84..e3e1256 100644 --- a/lib/ui/desktop/left_menus/navigation.dart +++ b/lib/ui/desktop/left_menus/navigation.dart @@ -116,7 +116,7 @@ class _LeftNavigationBarState extends State { backgroundColor: Theme.of(context).scaffoldBackgroundColor, selectedIconTheme: IconTheme.of(context).copyWith(color: Theme.of(context).colorScheme.primary, size: 22), unselectedIconTheme: - IconTheme.of(context).copyWith(color: IconTheme.of(context).color?.withOpacity(0.55), size: 22), + IconTheme.of(context).copyWith(color: IconTheme.of(context).color?.withValues(alpha: 0.55), size: 22), labelType: NavigationRailLabelType.all, destinations: destinations, selectedIndex: index, diff --git a/lib/ui/desktop/request/request.dart b/lib/ui/desktop/request/request.dart index f01e215..8d4e00c 100644 --- a/lib/ui/desktop/request/request.dart +++ b/lib/ui/desktop/request/request.dart @@ -360,8 +360,7 @@ class _RequestWidgetState extends State { var request = httpRequest.copy(uri: httpRequest.requestUrl); var proxyInfo = widget.proxyServer.isRunning ? ProxyInfo.of("127.0.0.1", widget.proxyServer.port) : null; HttpClients.proxyRequest(request, proxyInfo: proxyInfo); - - CustomToast.success(localizations.reSendRequest).show(context); + FlutterToastr.show(localizations.reSendRequest, context, rootNavigator: true); } PopupMenuItem popupItem(String text, {VoidCallback? onTap}) { diff --git a/lib/ui/desktop/setting/request_breakpoint.dart b/lib/ui/desktop/setting/request_breakpoint.dart new file mode 100644 index 0000000..a9b93ff --- /dev/null +++ b/lib/ui/desktop/setting/request_breakpoint.dart @@ -0,0 +1,503 @@ +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:proxypin/l10n/app_localizations.dart'; +import 'package:proxypin/network/components/manager/request_breakpoint_manager.dart'; +import 'package:proxypin/network/http/http.dart'; +import 'package:proxypin/ui/component/utils.dart'; +import 'package:proxypin/ui/component/widgets.dart'; + +import '../../component/app_dialog.dart' show CustomToast; +import '../../component/http_method_popup.dart'; + +class RequestBreakpointPage extends StatefulWidget { + final int? windowId; + + const RequestBreakpointPage({super.key, this.windowId}); + + @override + State createState() => _RequestBreakpointPageState(); +} + +class _RequestBreakpointPageState extends State { + AppLocalizations get localizations => AppLocalizations.of(context)!; + List rules = []; + bool enabled = false; + RequestBreakpointManager? manager; + + Set selected = {}; + bool isPressed = false; + Offset? lastPressPosition; + + Future _refreshConfig() async { + if (widget.windowId != null) { + await DesktopMultiWindow.invokeMethod(0, "refreshRequestBreakpoint"); + } + } + + Future _save() async { + await manager?.save(); + await _refreshConfig(); + } + + @override + void initState() { + super.initState(); + HardwareKeyboard.instance.addHandler(onKeyEvent); + RequestBreakpointManager.instance.then((value) { + manager = value; + setState(() { + enabled = value.enabled; + rules = value.list; + }); + }); + } + + @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; + } + if (widget.windowId != null) { + WindowController.fromWindowId(widget.windowId!).close(); + } + return true; + } + return false; + } + + @override + Widget build(BuildContext context) { + bool isEN = Localizations.localeOf(context).languageCode == 'en'; + + return Scaffold( + backgroundColor: Theme.of(context).dialogTheme.backgroundColor, + appBar: AppBar( + title: Text(localizations.breakpoint, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + toolbarHeight: 36, + centerTitle: true), + body: Center( + child: Container( + padding: const EdgeInsets.only(left: 15, right: 10), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + SizedBox( + width: isEN ? 280 : 250, + child: ListTile( + title: Text("${localizations.enable} ${localizations.breakpoint}"), + contentPadding: const EdgeInsets.only(left: 2), + trailing: SwitchWidget( + value: enabled, + scale: 0.8, + onChanged: (val) async { + manager?.enabled = val; + await _save(); + setState(() { + enabled = val; + }); + }))), + const SizedBox(width: 10), + Expanded( + child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + TextButton.icon( + icon: const Icon(Icons.add, size: 18), label: Text(localizations.add), onPressed: _editRule) + ])), + const SizedBox(width: 15) + ]), + const SizedBox(height: 10), + Expanded(child: _buildList()) + ])))); + } + + Widget _buildList() { + return GestureDetector( + onSecondaryTap: () { + if (lastPressPosition == null) { + return; + } + _showMenu(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), + decoration: BoxDecoration(border: Border.all(color: Colors.grey.withValues(alpha: 0.2))), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(left: 5, bottom: 5), + child: Row(mainAxisAlignment: MainAxisAlignment.start, children: [ + Container(width: 150, padding: const EdgeInsets.only(left: 10), child: Text(localizations.name)), + SizedBox(width: 50, child: Text(localizations.enable, textAlign: TextAlign.center)), + const VerticalDivider(width: 10), + Expanded(child: Text("URL", textAlign: TextAlign.center)), + SizedBox(width: 100, child: Text(localizations.breakpoint, textAlign: TextAlign.center)), + ]), + ), + const Divider(thickness: 0.5, height: 5), + Expanded( + child: ListView.builder( + itemCount: rules.length, + itemBuilder: (context, index) => _buildRow(index), + ), + ) + ], + ), + ), + ), + ); + } + + Widget _buildRow(int index) { + var primaryColor = Theme.of(context).colorScheme.primary; + var rule = rules[index]; + + return InkWell( + highlightColor: Colors.transparent, + splashColor: Colors.transparent, + hoverColor: primaryColor.withValues(alpha: 0.3), + onDoubleTap: () => _editRule(rule: rule), + onSecondaryTapDown: (details) => _showMenu(details.globalPosition, index: index), + onHover: (hover) { + if (isPressed && !selected.contains(index)) { + setState(() { + selected.add(index); + }); + } + }, + onTap: () { + if (HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) { + setState(() { + selected.contains(index) ? selected.remove(index) : selected.add(index); + }); + return; + } + if (selected.isEmpty) { + return; + } + setState(() { + selected.clear(); + }); + }, + child: Container( + color: selected.contains(index) + ? primaryColor.withValues(alpha: 0.5) + : index.isEven + ? Colors.grey.withValues(alpha: 0.1) + : null, + height: 32, + padding: const EdgeInsets.all(5), + child: Row(children: [ + SizedBox( + width: 150, + child: Text(rule.name?.isNotEmpty == true ? rule.name! : rule.url, + overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), + ), + SizedBox( + width: 50, + child: SwitchWidget( + scale: 0.65, + value: rule.enabled, + onChanged: (val) async { + rule.enabled = val; + await _save(); + })), + const SizedBox(width: 10), + Expanded(child: Text(rule.url, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center)), + SizedBox( + width: 100, + child: Text( + "${rule.interceptRequest ? localizations.request : ""}${rule.interceptRequest && rule.interceptResponse ? "/" : ""}${rule.interceptResponse ? localizations.response : ""}", + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis)), + ]), + ), + ); + } + + void _showMenu(Offset position, {int? index}) { + if (index != null) { + if (!selected.contains(index)) { + setState(() { + selected.clear(); + selected.add(index); + }); + } + } + + showContextMenu(context, position, items: [ + PopupMenuItem( + height: 32, + child: Text(localizations.edit), + onTap: () { + if (selected.length == 1) { + _editRule(rule: rules[selected.first]); + } + }, + ), + PopupMenuItem( + height: 32, + child: Text(localizations.delete), + onTap: () async { + if (selected.isEmpty) return; + var list = selected.toList(); + list.sort((a, b) => b.compareTo(a)); // Remove from end to avoid index shift issues + for (var i in list) { + rules.removeAt(i); + } + setState(() { + selected.clear(); + }); + await _save(); + }, + ), + ]); + } + + void _editRule({RequestBreakpointRule? rule}) { + showDialog( + context: context, + builder: (context) => InterceptRuleDialog(rule: rule), + ).then((value) async { + if (value != null && value is RequestBreakpointRule) { + setState(() { + if (rule == null) { + rules.add(value); + } + }); + await _save(); + } + }); + } +} + +class InterceptRuleDialog extends StatefulWidget { + final RequestBreakpointRule? rule; + + const InterceptRuleDialog({super.key, this.rule}); + + @override + State createState() => _InterceptRuleDialogState(); +} + +class _InterceptRuleDialogState extends State { + late RequestBreakpointRule rule; + final _formKey = GlobalKey(); + + late TextEditingController nameInput; + late TextEditingController urlInput; + + // Local state for methods to avoid modifying rule in-place before save + HttpMethod? _method; + bool _interceptRequest = true; + bool _interceptResponse = true; + + AppLocalizations get localizations => AppLocalizations.of(context)!; + + @override + void initState() { + super.initState(); + rule = widget.rule ?? RequestBreakpointRule(url: ''); + nameInput = TextEditingController(text: rule.name); + urlInput = TextEditingController(text: rule.url); + _method = rule.method; + _interceptRequest = rule.interceptRequest; + _interceptResponse = rule.interceptResponse; + } + + InputDecoration decoration(String label, {String? hintText}) { + return InputDecoration( + floatingLabelBehavior: FloatingLabelBehavior.always, + labelText: label, + hintText: hintText, + isDense: true, + border: const OutlineInputBorder()); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text( + widget.rule == null + ? "${localizations.add} ${localizations.breakpointRule}" + : "${localizations.edit} ${localizations.breakpointRule}", + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500)), + actionsPadding: const EdgeInsets.only(right: 15, bottom: 15), + contentPadding: const EdgeInsets.only(left: 20, right: 20, top: 15, bottom: 15), + content: Container( + constraints: const BoxConstraints(minWidth: 350, maxWidth: 500), + child: SingleChildScrollView( + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + 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), + Row(children: [ + SizedBox(width: 60, child: Text('URL:')), + Expanded( + child: TextFormField( + controller: urlInput, + style: const TextStyle(fontSize: 14), + validator: (val) => val?.isNotEmpty == true ? null : localizations.cannotBeEmpty, + 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: _method, + showSeparator: true, + onChanged: (val) { + setState(() { + _method = val; + }); + }, + ), + ), + ), + ), + ), + ]), + const SizedBox(height: 10), + Text(localizations.breakpoint, style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 13)), + const SizedBox(height: 5), + Container( + decoration: BoxDecoration( + // border: Border.all(color: Colors.grey.withValues(alpha: 0.5)), + borderRadius: BorderRadius.circular(5)), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: CheckboxListTile( + contentPadding: const EdgeInsets.only(left: 10), + title: Text(localizations.request, style: const TextStyle(fontSize: 14)), + value: _interceptRequest, + controlAffinity: ListTileControlAffinity.leading, + dense: true, + visualDensity: const VisualDensity(vertical: -4), + onChanged: (val) { + setState(() { + _interceptRequest = val!; + }); + }, + )), + // Container(height: 30, width: 0.5, color: Colors.grey.withValues(alpha: 0.5)), + Expanded( + child: CheckboxListTile( + contentPadding: const EdgeInsets.only(left: 10), + title: Text(localizations.response, style: const TextStyle(fontSize: 14)), + value: _interceptResponse, + controlAffinity: ListTileControlAffinity.leading, + dense: true, + visualDensity: const VisualDensity(vertical: -4), + onChanged: (val) { + setState(() { + _interceptResponse = val!; + }); + }, + )), + Expanded(child: SizedBox()), + ], + ), + ), + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(localizations.cancel), + ), + FilledButton( + onPressed: () { + if (!(_formKey.currentState?.validate() ?? false)) { + CustomToast.error("URL ${localizations.cannotBeEmpty}").show(context, alignment: Alignment.topCenter); + return; + } + + rule.name = nameInput.text; + rule.url = urlInput.text; + rule.method = _method; + rule.interceptRequest = _interceptRequest; + rule.interceptResponse = _interceptResponse; + Navigator.pop(context, rule); + }, + child: Text(localizations.save), + ), + ], + ); + } + + 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)); + } +} diff --git a/lib/ui/desktop/setting/setting.dart b/lib/ui/desktop/setting/setting.dart index a3d3cab..c303966 100644 --- a/lib/ui/desktop/setting/setting.dart +++ b/lib/ui/desktop/setting/setting.dart @@ -78,6 +78,7 @@ class _SettingState extends State { item(localizations.requestCrypto, onPressed: showRequestCrypto), item(localizations.script, onPressed: () => MultiWindow.openWindow(localizations.script, 'ScriptWidget', size: const Size(800, 780))), + item(localizations.breakpoint, onPressed: requestBreakpoint), item(localizations.externalProxy, onPressed: setExternalProxy), item(localizations.about, onPressed: showAbout), ], @@ -109,10 +110,13 @@ class _SettingState extends State { ///请求重写Dialog void requestRewrite() async { - if (!mounted) return; MultiWindow.openWindow(localizations.requestRewrite, 'RequestRewriteWidget', size: const Size(800, 750)); } + void requestBreakpoint() async { + MultiWindow.openWindow(localizations.breakpoint, 'RequestBreakpointPage', size: const Size(800, 750)); + } + ///请求本地映射 void requestMap() async { if (!mounted) return;