implement request breakpoint management UI and functionality (#669)(#660)(#386)

This commit is contained in:
wanghongenpin
2026-02-23 17:08:12 +08:00
parent ff06ac924a
commit 1148c837e7
10 changed files with 645 additions and 14 deletions

View File

@@ -20,7 +20,5 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>

View File

@@ -174,6 +174,8 @@ enum Operation {
}
class MultiWindow {
static Function(String widgetName, Map<String, dynamic>? args)? onOpenWindow;
/// 刷新请求重写
static Future<void> invokeRefreshRewrite(Operation operation,
{int? index, RequestRewriteRule? rule, List<RewriteItem>? items, bool? enabled}) async {
@@ -188,6 +190,11 @@ class MultiWindow {
static Future<WindowController> openWindow(String title, String widgetName,
{Size size = const Size(800, 680), Map<String, dynamic>? args}) async {
if (Platform.isAndroid || Platform.isIOS) {
onOpenWindow?.call(widgetName, args);
return WindowController.fromWindowId(0); // Dummy controller
}
var ratio = 1.0;
if (Platform.isWindows) {
ratio = WindowManager.instance.getDevicePixelRatio();

View File

@@ -221,7 +221,7 @@ class _RequestBreakpointPageState extends State<RequestBreakpointPage> {
child: Row(children: [
SizedBox(
width: 150,
child: Text(rule.name?.isNotEmpty == true ? rule.name! : rule.url,
child: Text(rule.name ?? "",
overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
),
SizedBox(

View File

@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:proxypin/network/bin/server.dart';
import 'package:proxypin/network/http/http.dart';
import 'package:proxypin/ui/mobile/request/request_editor.dart';
import 'package:proxypin/ui/mobile/request/request_editor_source.dart';
class BreakpointExecutor extends StatefulWidget {
final HttpRequest request;
final HttpResponse? response;
final String requestId;
// false: intercept request, true: intercept response
final bool isResponse;
const BreakpointExecutor({
super.key,
required this.request,
this.response,
required this.requestId,
required this.isResponse,
});
@override
State<BreakpointExecutor> createState() => _BreakpointExecutorState();
}
class _BreakpointExecutorState extends State<BreakpointExecutor> {
late HttpRequest request;
late HttpResponse? response;
@override
void initState() {
super.initState();
request = widget.request;
response = widget.response;
}
@override
Widget build(BuildContext context) {
if (widget.isResponse) {
return _buildResponseBody();
}
return MobileRequestEditor(
request: request,
proxyServer: ProxyServer.current,
source: RequestEditorSource.breakpointRequest,
onExecuteRequest: (newRequest) async {
if (Navigator.canPop(context)) {
Navigator.pop(context, newRequest);
}
},
);
}
Widget _buildResponseBody() {
return MobileRequestEditor(
request: request,
response: response,
proxyServer: ProxyServer.current,
source: RequestEditorSource.breakpointResponse,
onExecuteResponse: (newResponse) async {
if (Navigator.canPop(context)) {
Navigator.pop(context, newResponse);
}
},
);
}
}

View File

@@ -38,6 +38,7 @@ import 'package:proxypin/ui/mobile/setting/request_rewrite.dart';
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 'package:proxypin/ui/mobile/setting/request_breakpoint.dart';
import '../../component/widgets.dart';
import '../setting/proxy.dart';
@@ -158,6 +159,12 @@ class _ConfigPageState extends State<ConfigPage> {
leading: Icon(Icons.javascript_outlined, color: color),
trailing: arrow,
onTap: () => navigator(context, const MobileScript())),
Divider(height: 0, thickness: 0.3, color: Theme.of(context).dividerColor.withValues(alpha: 0.22)),
ListTile(
title: Text(localizations.breakpoint),
leading: Icon(Icons.bug_report_outlined, color: color),
trailing: arrow,
onTap: () => navigator(context, const MobileRequestBreakpointPage())),
]),
const SizedBox(height: 16)
],

View File

@@ -15,6 +15,7 @@
*/
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
@@ -50,6 +51,8 @@ import 'package:proxypin/utils/listenable_list.dart';
import 'package:proxypin/utils/navigator.dart';
import '../app_update/app_update_repository.dart';
import 'package:proxypin/ui/component/multi_window.dart';
import 'package:proxypin/ui/mobile/debug/breakpoint_executor.dart';
///移动端首页
///@author wanghongen
@@ -124,6 +127,26 @@ class MobileHomeState extends State<MobileHomePage> implements EventListener, Li
} else if (Platform.isAndroid) {
AppUpdateRepository.checkUpdate(context);
}
// Handle breakpoint window on mobile
MultiWindow.onOpenWindow = (widgetName, args) async {
if (widgetName == 'BreakpointExecutor' && args != null) {
if (!mounted) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BreakpointExecutor(
requestId: args['requestId'],
request: HttpRequest.fromJson(jsonDecode(jsonEncode(args['request']))),
response: args['response'] == null
? null
: HttpResponse.fromJson(jsonDecode(jsonEncode(args['response']))),
isResponse: args['type'] == 'response',
),
),
);
}
};
}
@override

View File

@@ -30,14 +30,29 @@ import 'package:proxypin/ui/content/body.dart';
import 'package:proxypin/utils/curl.dart';
import 'package:proxypin/utils/lang.dart';
import 'package:proxypin/ui/mobile/request/request_editor_source.dart';
import '../../component/http_method_popup.dart';
/// @author wanghongen
class MobileRequestEditor extends StatefulWidget {
final HttpRequest? request;
final ProxyServer? proxyServer;
final RequestEditorSource source;
final Function(HttpRequest request)? onExecuteRequest;
final Function(HttpResponse response)? onExecuteResponse;
final HttpResponse? response;
const MobileRequestEditor({super.key, this.request, required this.proxyServer});
const MobileRequestEditor({
super.key,
this.request,
this.response,
required this.proxyServer,
this.source = RequestEditorSource.editor,
this.onExecuteRequest,
this.onExecuteResponse,
});
@override
State<StatefulWidget> createState() {
@@ -79,6 +94,7 @@ class RequestEditorState extends State<MobileRequestEditor> with SingleTickerPro
tabController = TabController(length: tabs.length, vsync: this);
request = widget.request;
response = widget.response;
if (widget.request == null) {
curlParse();
}
@@ -134,6 +150,14 @@ class RequestEditorState extends State<MobileRequestEditor> with SingleTickerPro
];
}
var buttonText = localizations.send;
IconData icon = Icons.send;
if (widget.source == RequestEditorSource.breakpointRequest ||
widget.source == RequestEditorSource.breakpointResponse) {
buttonText = "Execute";
icon = Icons.play_arrow;
}
return Scaffold(
appBar: AppBar(
title: Text(localizations.httpRequest, style: const TextStyle(fontSize: 16)),
@@ -143,7 +167,16 @@ class RequestEditorState extends State<MobileRequestEditor> with SingleTickerPro
onPressed: () => Navigator.pop(context),
child: Text(localizations.cancel, style: Theme.of(context).textTheme.bodyMedium)),
actions: [
TextButton.icon(icon: const Icon(Icons.send), label: Text(localizations.send), onPressed: sendRequest)
TextButton.icon(
icon: Icon(icon),
label: Text(buttonText),
onPressed: () {
if (widget.source == RequestEditorSource.editor) {
sendRequest();
} else {
executeBreakpoint();
}
})
],
bottom: TabBar(controller: tabController, tabs: tabs)),
body: GestureDetector(
@@ -156,6 +189,7 @@ class RequestEditorState extends State<MobileRequestEditor> with SingleTickerPro
message: request,
key: requestKey,
urlQueryNotifier: _queryNotifier,
readOnly: widget.source == RequestEditorSource.breakpointResponse,
),
ValueListenableBuilder(
valueListenable: responseChange,
@@ -176,7 +210,7 @@ class RequestEditorState extends State<MobileRequestEditor> with SingleTickerPro
style: TextStyle(
color: response?.status.isSuccessful() == true ? Colors.blue : Colors.red))
]),
readOnly: true,
readOnly: widget.source == RequestEditorSource.breakpointRequest,
message: response);
}),
],
@@ -215,6 +249,32 @@ class RequestEditorState extends State<MobileRequestEditor> with SingleTickerPro
tabController.animateTo(1);
}
void executeBreakpoint() {
if (widget.source == RequestEditorSource.breakpointRequest) {
var currentState = requestLineKey.currentState!;
var headers = requestKey.currentState?.getHeaders();
var requestBody = requestKey.currentState?.getBody();
String url = currentState.requestUrl.text;
HttpRequest newRequest = request!.copy(uri: url);
newRequest.method = currentState.requestMethod;
newRequest.headers.clear();
newRequest.headers.addAll(headers);
newRequest.body = requestBody == null ? null : utf8.encode(requestBody);
widget.onExecuteRequest?.call(newRequest);
} else if (widget.source == RequestEditorSource.breakpointResponse) {
var headers = responseKey.currentState?.getHeaders();
var responseBody = responseKey.currentState?.getBody();
if (response == null) return;
HttpResponse newResponse = response!.copy();
newResponse.headers.clear();
newResponse.headers.addAll(headers);
newResponse.body = responseBody == null ? null : utf8.encode(responseBody);
widget.onExecuteResponse?.call(newResponse);
}
}
}
typedef ParamCallback = void Function(String param);

View File

@@ -0,0 +1,6 @@
enum RequestEditorSource {
editor,
breakpointRequest,
breakpointResponse,
}

View File

@@ -0,0 +1,464 @@
import 'dart:collection';
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_breakpoint_manager.dart';
import 'package:proxypin/network/http/http.dart';
import 'package:proxypin/network/util/logger.dart';
import 'package:proxypin/ui/component/widgets.dart';
import '../../component/http_method_popup.dart';
class MobileRequestBreakpointPage extends StatefulWidget {
const MobileRequestBreakpointPage({super.key});
@override
State<MobileRequestBreakpointPage> createState() => _RequestBreakpointPageState();
}
class _RequestBreakpointPageState extends State<MobileRequestBreakpointPage> {
AppLocalizations get localizations => AppLocalizations.of(context)!;
List<RequestBreakpointRule> rules = [];
bool enabled = false;
RequestBreakpointManager? manager;
bool selectionMode = false;
final Set<int> selected = HashSet<int>();
Future<void> _save() async {
await manager?.save();
}
@override
void initState() {
super.initState();
RequestBreakpointManager.instance.then((value) {
manager = value;
setState(() {
enabled = value.enabled;
rules = value.list;
});
});
}
@override
void dispose() {
super.dispose();
}
@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()),
if (selectionMode) _buildSelectionFooter(),
]))));
}
Widget _buildList() {
return 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: 65, padding: const EdgeInsets.only(left: 10), child: Text(localizations.name)),
SizedBox(width: 45, 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),
onLongPress: () => _showRuleActions(index),
onTap: () {
if (selectionMode) {
setState(() {
if (!selected.add(index)) {
selected.remove(index);
}
});
return;
}
_editRule(rule: rule);
},
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: 65,
child: Text(rule.name ?? "",
overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
),
SizedBox(
width: 45,
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)),
]),
),
);
}
Future<void> _export(RequestBreakpointManager? manager, {List<int>? indexes}) async {
try {
if (manager == null || manager.list.isEmpty) return;
final rules = manager.list;
final keys = (indexes == null || indexes.isEmpty)
? List<int>.generate(rules.length, (i) => i)
: (indexes.toList()..sort());
final data = keys.map((i) => rules[i].toJson()).toList();
var bytes = utf8.encode(jsonEncode(data));
final path = await FilePicker.platform.saveFile(fileName: 'request_breakpoints.json', bytes: bytes);
if (path == null) return;
if (mounted) FlutterToastr.show(localizations.exportSuccess, context);
} catch (e) {
logger.e('导出失败', error: e);
if (mounted) FlutterToastr.show('Export failed: $e', context);
}
}
Stack _buildSelectionFooter() {
final l10n = localizations;
return Stack(children: [
Container(
height: 50,
width: double.infinity,
margin: const EdgeInsets.only(top: 10),
decoration: BoxDecoration(border: Border.all(color: Colors.grey.withValues(alpha: 0.2)))),
Positioned(
top: 0,
left: 0,
right: 0,
child: Center(
child: TextButton(
onPressed: () {},
child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
TextButton.icon(
onPressed: selected.isEmpty
? null
: () async {
// export selected only
final m = await RequestBreakpointManager.instance;
await _export(m, indexes: selected.toList());
setState(() {
selected.clear();
selectionMode = false;
});
},
icon: const Icon(Icons.share, size: 18),
label: Text(l10n.export, style: const TextStyle(fontSize: 14))),
TextButton.icon(
onPressed: selected.isEmpty ? null : () => _removeSelected(),
icon: const Icon(Icons.delete, size: 18),
label: Text(l10n.delete, style: const TextStyle(fontSize: 14))),
TextButton.icon(
onPressed: () {
setState(() {
selectionMode = false;
selected.clear();
});
},
icon: const Icon(Icons.cancel, size: 18),
label: Text(l10n.cancel, style: const TextStyle(fontSize: 14))),
]))))
]);
}
void _showRuleActions(int index) {
final l10n = localizations;
setState(() {
selected.add(index);
});
showModalBottomSheet(
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(10))),
context: context,
enableDrag: true,
builder: (ctx) {
return Wrap(children: [
BottomSheetItem(
text: l10n.multiple,
onPressed: () {
setState(() => selectionMode = true);
}),
const Divider(thickness: 0.5, height: 5),
BottomSheetItem(
text: l10n.edit,
onPressed: () {
_editRule(rule: rules[index]);
}),
const Divider(thickness: 0.5, height: 5),
BottomSheetItem(text: l10n.export, onPressed: () => _export(manager, indexes: [index])),
const Divider(thickness: 0.5, height: 5),
BottomSheetItem(
text: rules[index].enabled ? l10n.disabled : l10n.enable,
onPressed: () {
rules[index].enabled = !rules[index].enabled;
setState(() {});
_save();
}),
const Divider(thickness: 0.5, height: 5),
BottomSheetItem(
text: l10n.delete,
onPressed: () {
_removeRule(index);
}),
Container(color: Theme.of(ctx).hoverColor, height: 8),
TextButton(
child: Container(
height: 45,
width: double.infinity,
padding: const EdgeInsets.only(top: 10),
child: Text(l10n.cancel, textAlign: TextAlign.center)),
onPressed: () {
Navigator.of(ctx).pop();
}),
]);
}).then((value) {
if (selectionMode) {
return;
}
setState(() {
selected.remove(index);
});
});
}
Future<void> _removeRule(int index) async {
showDialog(
context: context,
builder: (ctx) {
return AlertDialog(
title: Text(localizations.deleteHeaderConfirm, style: const TextStyle(fontSize: 18)),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(localizations.cancel)),
TextButton(
onPressed: () async {
setState(() {
rules.removeAt(index);
});
await _save();
if (context.mounted) Navigator.pop(ctx);
},
child: Text(localizations.delete)),
],
);
});
}
Future<void> _removeSelected() async {
showDialog(
context: context,
builder: (ctx) {
return AlertDialog(
title: Text(localizations.deleteHeaderConfirm, style: const TextStyle(fontSize: 18)),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(localizations.cancel)),
TextButton(
onPressed: () async {
var list = selected.toList();
list.sort((a, b) => b.compareTo(a));
for (var i in list) {
rules.removeAt(i);
}
setState(() {
selected.clear();
selectionMode = false;
});
await _save();
if (context.mounted) Navigator.pop(ctx);
},
child: Text(localizations.delete)),
],
);
});
}
void _editRule({RequestBreakpointRule? rule}) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MobileBreakpointRuleEditor(rule: rule),
),
).then((value) async {
if (value != null && value is RequestBreakpointRule) {
setState(() {
if (rule == null) {
rules.add(value);
}
});
await _save();
}
});
}
}
class MobileBreakpointRuleEditor extends StatefulWidget {
final RequestBreakpointRule? rule;
const MobileBreakpointRuleEditor({super.key, this.rule});
@override
State<MobileBreakpointRuleEditor> createState() => _MobileBreakpointRuleEditorState();
}
class _MobileBreakpointRuleEditorState extends State<MobileBreakpointRuleEditor> {
late RequestBreakpointRule rule;
final _formKey = GlobalKey<FormState>();
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;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
widget.rule == null
? "${localizations.add} ${localizations.breakpointRule}"
: "${localizations.edit} ${localizations.breakpointRule}",
style: const TextStyle(fontSize: 16)),
actions: [
TextButton(
onPressed: () {
if (!(_formKey.currentState?.validate() ?? false)) {
return;
}
rule.name = nameInput.text;
rule.url = urlInput.text;
rule.method = _method;
rule.interceptRequest = _interceptRequest;
rule.interceptResponse = _interceptResponse;
rule.enabled = true;
Navigator.pop(context, rule);
},
child: Text(localizations.save))
]),
body: Padding(
padding: const EdgeInsets.all(15),
child: Form(
key: _formKey,
child: ListView(children: [
TextFormField(
controller: nameInput,
decoration: InputDecoration(labelText: localizations.name, border: const OutlineInputBorder()),
),
const SizedBox(height: 15),
TextFormField(
controller: urlInput,
validator: (val) => val?.isNotEmpty == true ? null : localizations.cannotBeEmpty,
decoration: InputDecoration(
labelText: 'URL',
hintText: 'https://www.example.com/api/*',
border: const OutlineInputBorder(),
prefixIcon: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5),
child: MethodPopupMenu(value: _method, onChanged: (val) => setState(() => _method = val)))),
),
const SizedBox(height: 15),
SwitchListTile(
title: Text(localizations.request),
value: _interceptRequest,
onChanged: (val) => setState(() => _interceptRequest = val)),
SwitchListTile(
title: Text(localizations.response),
value: _interceptResponse,
onChanged: (val) => setState(() => _interceptResponse = val)),
]))));
}
}

View File

@@ -264,11 +264,9 @@ class _MobileRequestCryptoPageState extends State<MobileRequestCryptoPage> {
setState(() => selectionMode = true);
}),
const Divider(thickness: 0.5, height: 5),
ListTile(
leading: const Icon(Icons.edit_outlined),
title: Text(l10n.edit),
onTap: () {
Navigator.pop(ctx);
BottomSheetItem(
text: l10n.edit,
onPressed: () {
_editRule(manager, index);
}),
const Divider(thickness: 0.5, height: 5),
@@ -286,7 +284,6 @@ class _MobileRequestCryptoPageState extends State<MobileRequestCryptoPage> {
BottomSheetItem(
text: l10n.delete,
onPressed: () {
Navigator.pop(ctx);
_removeRule(manager, index);
}),
Container(color: Theme.of(ctx).hoverColor, height: 8),
@@ -367,9 +364,9 @@ class _MobileRequestCryptoPageState extends State<MobileRequestCryptoPage> {
? List<int>.generate(manager.rules.length, (i) => i)
: (indexes.toList()..sort());
final data = keys.map((i) => manager.rules[i].toJson()).toList();
final path = await FilePicker.platform.saveFile(fileName: 'request_crypto.json');
var bytes = utf8.encode(jsonEncode(data));
final path = await FilePicker.platform.saveFile(fileName: 'request_crypto.json', bytes: bytes);
if (path == null) return;
await File(path).writeAsString(jsonEncode(data));
if (mounted) FlutterToastr.show(localizations.exportSuccess, context);
} catch (e) {
logger.e('导出失败', error: e);