Files
proxypin/lib/ui/content/body.dart
2026-04-09 21:32:22 +08:00

854 lines
26 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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 'dart:math';
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_toastr/flutter_toastr.dart';
import 'package:image_pickers/image_pickers.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/content_type.dart';
import 'package:proxypin/network/http/http.dart';
import 'package:proxypin/network/util/logger.dart';
import 'package:proxypin/ui/component/json/json_viewer.dart';
import 'package:proxypin/ui/component/json/theme.dart';
import 'package:proxypin/ui/component/multi_window.dart';
import 'package:proxypin/ui/component/utils.dart';
import 'package:proxypin/ui/desktop/setting/request_rewrite.dart';
import 'package:proxypin/ui/mobile/setting/request_rewrite.dart';
import 'package:proxypin/utils/crypto_body_decoder.dart';
import 'package:proxypin/utils/css_formatter.dart';
import 'package:proxypin/utils/html_formatter.dart';
import 'package:proxypin/utils/js_formatter.dart';
import 'package:proxypin/utils/lang.dart';
import 'package:proxypin/utils/num.dart';
import 'package:proxypin/utils/platform.dart';
import 'package:proxypin/utils/xml_formatter.dart';
import 'package:window_manager/window_manager.dart';
import '../component/json/json_text.dart';
import '../component/search/highlight_text.dart';
import '../component/search/search_controller.dart';
import '../component/search/virtualized_highlight_text.dart';
import '../toolbox/encoder.dart';
///请求响应的body部分
///@Author wanghongen
class HttpBodyWidget extends StatefulWidget {
final HttpMessage? httpMessage;
final bool inNewWindow; //是否在新窗口打开
final WindowController? windowController;
final ScrollController? scrollController;
final bool hideRequestRewrite; //是否隐藏请求重写
const HttpBodyWidget(
{super.key,
required this.httpMessage,
this.inNewWindow = false,
this.windowController,
this.scrollController,
this.hideRequestRewrite = false});
@override
State<StatefulWidget> createState() {
return HttpBodyState();
}
}
class HttpBodyState extends State<HttpBodyWidget> {
var bodyKey = GlobalKey<_BodyState>();
int tabIndex = 0;
final searchIconKey = GlobalKey();
final SearchTextController searchController = SearchTextController();
AppLocalizations get localizations => AppLocalizations.of(context)!;
bool showDecoded = false;
CryptoDecodedResult? decoded;
@override
void initState() {
super.initState();
if (widget.windowController != null) {
HardwareKeyboard.instance.addHandler(onKeyEvent);
}
_loadDecoded();
}
@override
void didUpdateWidget(covariant HttpBodyWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.httpMessage?.requestId != widget.httpMessage?.requestId) {
showDecoded = false;
decoded = null;
_loadDecoded();
}
}
/// 按键事件
bool onKeyEvent(KeyEvent event) {
if ((HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) &&
event.logicalKey == LogicalKeyboardKey.keyW) {
HardwareKeyboard.instance.removeHandler(onKeyEvent);
widget.windowController?.close();
return true;
}
return false;
}
Future<void> _loadDecoded() async {
final message = widget.httpMessage;
if (message == null) return;
decoded = await CryptoBodyDecoder.maybeDecode(message);
if (mounted && decoded != null && decoded!.hasText) {
setState(() {});
}
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(onKeyEvent);
searchController.dispose();
widget.scrollController?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.httpMessage == null) {
return const SizedBox();
}
if ((widget.httpMessage?.body == null || widget.httpMessage?.body?.isEmpty == true) &&
widget.httpMessage?.messages.isNotEmpty == false) {
return const SizedBox();
}
var tabs = Tabs.of(widget.httpMessage?.contentType, isJsonText());
if (tabIndex > 0 && tabIndex >= tabs.list.length) tabIndex = tabs.list.length - 1;
bodyKey.currentState?.changeState(widget.httpMessage, tabs.list[tabIndex]);
//TabBar
List<Widget> list = [
widget.inNewWindow ? const SizedBox() : titleWidget(),
const SizedBox(height: 3),
SizedBox(
height: 36,
child: TabBar(
labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
labelPadding: const EdgeInsets.only(left: 3, right: 5),
tabs: tabs.tabList(),
onTap: (index) {
tabIndex = index;
bodyKey.currentState?.changeState(widget.httpMessage, tabs.list[tabIndex]);
})),
Padding(
padding: const EdgeInsets.all(10),
child: _Body(
key: bodyKey,
message: widget.httpMessage,
viewType: tabs.list[tabIndex],
scrollController: widget.scrollController,
searchController: searchController)) //body
];
var tabController = FocusableActionDetector(
shortcuts: {
LogicalKeySet(
Platform.isMacOS ? LogicalKeyboardKey.meta : LogicalKeyboardKey.control, LogicalKeyboardKey.keyF):
ActivateIntent(),
LogicalKeySet(LogicalKeyboardKey.escape): DismissIntent(),
},
actions: {
ActivateIntent: CallbackAction<ActivateIntent>(
onInvoke: (intent) {
if (searchController.isSearchOverlayVisible) {
hideSearchOverlay();
} else {
RenderBox renderBox = searchIconKey.currentContext?.findRenderObject() as RenderBox;
Offset position = renderBox.localToGlobal(Offset.zero); // 获取搜索图标的位置
searchController.showSearchOverlay(context,
top: max(position.dy + renderBox.size.height + 50, 100), right: 10);
}
return null;
},
),
DismissIntent: CallbackAction<DismissIntent>(
onInvoke: (intent) {
hideSearchOverlay();
return null;
},
),
},
child: DefaultTabController(
initialIndex: tabIndex,
length: tabs.list.length,
child: widget.inNewWindow
? ListView(children: list)
: Column(crossAxisAlignment: CrossAxisAlignment.start, children: list)));
//在新窗口打开
if (widget.inNewWindow) {
return Scaffold(
appBar: AppBar(title: titleWidget(inNewWindow: true), toolbarHeight: Platform.isWindows ? 36 : null),
body: tabController);
}
return tabController;
}
void hideSearchOverlay() {
searchController.removeSearchOverlay();
}
//判断是否是json格式
bool isJsonText() {
var bodyString = widget.httpMessage?.bodyAsString;
return bodyString != null &&
(bodyString.startsWith('{') && bodyString.endsWith('}') ||
bodyString.startsWith('[') && bodyString.endsWith(']'));
}
/// 标题
Widget titleWidget({bool inNewWindow = false}) {
var type = widget.httpMessage is HttpRequest ? "Request" : "Response";
bool isImage = widget.httpMessage?.contentType == ContentType.image;
VisualDensity visualDensity = Platforms.isMobile() ? VisualDensity.compact : VisualDensity.standard;
final isMobile = Platforms.isMobile();
// Build common actions as widgets so we can either display them inline (desktop)
// or move them into an overflow menu (mobile) to avoid hiding important buttons.
final searchBtn = InkWell(
key: searchIconKey,
child: const Icon(Icons.search, size: 20),
onTap: () {
if (searchController.isSearchOverlayVisible) {
searchController.removeSearchOverlay();
} else {
RenderBox renderBox = searchIconKey.currentContext?.findRenderObject() as RenderBox;
Offset position = renderBox.localToGlobal(Offset.zero);
searchController.showSearchOverlay(context, top: position.dy + renderBox.size.height + 50, right: 10);
}
},
);
final copyBtn = isImage
? downloadImageButton()
: IconButton(
visualDensity: visualDensity,
iconSize: 16,
icon: const Icon(Icons.copy),
tooltip: localizations.copy,
onPressed: () async {
var body = await bodyKey.currentState?.getBody();
if (body == null) return;
Clipboard.setData(ClipboardData(text: body)).then((_) {
if (mounted) FlutterToastr.show(localizations.copied, context);
});
},
);
final rewriteBtn = IconButton(
visualDensity: visualDensity,
iconSize: 16,
icon: const Icon(Icons.edit_document),
tooltip: localizations.requestRewrite,
onPressed: showRequestRewrite,
);
final encodeBtn = IconButton(
visualDensity: visualDensity,
iconSize: 20,
icon: const Icon(Icons.text_format),
tooltip: localizations.encode,
onPressed: () async {
var body = await bodyKey.currentState?.getBody();
if (mounted) {
encodeWindow(EncoderType.base64, context, body);
}
});
final openNewBtn = IconButton(
visualDensity: visualDensity,
iconSize: 16,
icon: const Icon(Icons.open_in_new),
tooltip: localizations.newWindow,
onPressed: () => openNew());
Widget? cryptoToggle;
if (decoded != null) {
cryptoToggle = TextButton.icon(
onPressed: () {
setState(() {
showDecoded = !showDecoded;
});
},
icon: Icon(showDecoded ? Icons.lock_open : Icons.lock, size: 18),
label: Text(showDecoded ? localizations.cryptoDecoded : localizations.cryptoDecodeToggle),
);
}
// Mobile UX:
// - If there is NO crypto result, keep the original (previous) horizontal-scroll title bar.
// - Only when crypto is available, switch to the compact overflow-menu layout to keep
// the crypto toggle visible.
if (isMobile && cryptoToggle != null) {
final overflowItems = <PopupMenuEntry<String>>[];
if (!widget.hideRequestRewrite) {
overflowItems.add(PopupMenuItem(value: 'rewrite', child: Text(localizations.requestRewrite)));
}
overflowItems.add(PopupMenuItem(value: 'encode', child: Text(localizations.encode)));
if (!inNewWindow) {
overflowItems.add(PopupMenuItem(value: 'new_window', child: Text(localizations.newWindow)));
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('$type Body', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
const SizedBox(width: 8),
searchBtn,
const SizedBox(width: 4),
copyBtn,
const SizedBox(width: 4),
Flexible(child: cryptoToggle),
if (overflowItems.isNotEmpty)
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, size: 20),
onSelected: (v) {
if (v == 'rewrite') showRequestRewrite();
if (v == 'encode') {
bodyKey.currentState?.getBody().then((body) {
if (mounted) encodeWindow(EncoderType.base64, context, body);
});
}
if (v == 'new_window') openNew();
},
itemBuilder: (_) => overflowItems,
),
],
);
}
// Default (desktop + mobile without crypto): keep the previous full inline actions
// (horizontal scroll when needed).
final list = <Widget>[
Text('$type Body', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
const SizedBox(width: 18),
searchBtn,
const SizedBox(width: 4),
copyBtn,
];
if (!widget.hideRequestRewrite) {
list.add(rewriteBtn);
}
list.add(encodeBtn);
if (!inNewWindow) {
list.add(openNewBtn);
}
if (cryptoToggle != null) {
list.add(cryptoToggle);
}
return SingleChildScrollView(scrollDirection: Axis.horizontal, child: Row(children: list));
}
///下载图片
Widget downloadImageButton() {
return IconButton(
iconSize: 19,
visualDensity: VisualDensity.comfortable,
icon: Icon(Icons.download),
tooltip: localizations.saveImage,
onPressed: () async {
var body = bodyKey.currentState?.message?.body;
if (body == null) {
return;
}
var bytes = Uint8List.fromList(body);
if (Platforms.isMobile()) {
String? path = await ImagePickers.saveByteDataImageToGallery(bytes);
if (path != null && mounted) {
FlutterToastr.show(localizations.saveSuccess, context, duration: 2, rootNavigator: true);
}
return;
}
if (Platforms.isDesktop()) {
var fileName = "image_${DateTime.now().millisecondsSinceEpoch}.png";
String? path = (await FilePicker.platform.saveFile(fileName: fileName));
if (path == null) return;
await File(path).writeAsBytes(bytes);
if (mounted) {
FlutterToastr.show(localizations.saveSuccess, context, duration: 2);
}
}
});
}
///展示请求重写
Future<void> showRequestRewrite() async {
HttpRequest? request;
if (widget.httpMessage == null) {
return;
}
bool isRequest = widget.httpMessage is HttpRequest;
if (widget.httpMessage is HttpRequest) {
request = widget.httpMessage as HttpRequest;
} else {
request = (widget.httpMessage as HttpResponse).request;
}
var requestRewrites = await RequestRewriteManager.instance;
var ruleType = isRequest ? RuleType.requestReplace : RuleType.responseReplace;
var rule = requestRewrites.getRequestRewriteRule(request!, ruleType);
var rewriteItems = await requestRewrites.getRewriteItems(rule);
if (!mounted) return;
if (Platforms.isMobile()) {
Navigator.push(
context, MaterialPageRoute(builder: (_) => RewriteRule(rule: rule, items: rewriteItems, request: request)));
} else {
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) => RewriteRuleEdit(rule: rule, items: rewriteItems, request: request))
.then((value) {
if (value is RequestRewriteRule && mounted) {
FlutterToastr.show(localizations.saveSuccess, context);
}
});
}
}
///打开新窗口
void openNew() async {
if (Platforms.isDesktop()) {
var size = MediaQuery.of(context).size;
var ratio = 1.0;
if (Platform.isWindows) {
ratio = WindowManager.instance.getDevicePixelRatio();
}
final window = await DesktopMultiWindow.createWindow(jsonEncode(
{'name': 'HttpBodyWidget', 'httpMessage': widget.httpMessage, 'inNewWindow': true},
));
window
..setTitle(widget.httpMessage is HttpRequest ? localizations.requestBody : localizations.responseBody)
..setFrame(const Offset(100, 100) & Size(800 * ratio, size.height * ratio))
..center()
..show();
return;
}
Navigator.push(
context, MaterialPageRoute(builder: (_) => HttpBodyWidget(httpMessage: widget.httpMessage, inNewWindow: true)));
}
}
class _Body extends StatefulWidget {
final HttpMessage? message;
final ViewType viewType;
final ScrollController? scrollController;
final SearchTextController searchController; // 添加搜索设置控制器
const _Body({
super.key,
this.message,
required this.viewType,
this.scrollController,
required this.searchController, // 添加必需参数
});
@override
State<StatefulWidget> createState() {
return _BodyState();
}
}
class _BodyState extends State<_Body> {
static const int _virtualizedThreshold = 100000;
late ViewType viewType;
HttpMessage? message;
@override
void initState() {
super.initState();
viewType = widget.viewType;
message = widget.message;
}
void changeState(HttpMessage? message, ViewType viewType) {
setState(() {
this.message = message;
this.viewType = viewType;
});
}
@override
Widget build(BuildContext context) {
return _getBody(viewType);
}
HttpMessage? _effectiveMessage(HttpBodyState? parent) {
if (parent?.showDecoded == true && parent?.decoded != null && message != null) {
return _DecodedHttpMessage(message!, parent!.decoded!);
}
return message;
}
String _formatTextBody(ViewType type, String body) {
try {
if (type == ViewType.formUrl) {
return Uri.decodeFull(body);
}
if (type == ViewType.html) {
return HTML.pretty(body);
}
if (type == ViewType.xml) {
return XML.pretty(body);
}
if (type == ViewType.css) {
return CSS.pretty(body);
}
if (type == ViewType.js) {
return JS.pretty(body);
}
if (type == ViewType.jsonText || type == ViewType.json) {
var jsonObject = json.decode(body);
return const JsonEncoder.withIndent(" ").convert(jsonObject);
}
} catch (_) {}
return body;
}
Future<String?> getBody() async {
final parent = context.findAncestorStateOfType<HttpBodyState>();
final currentMessage = _effectiveMessage(parent);
if (currentMessage?.isWebSocket == true) {
return currentMessage?.messages.map((e) => e.payloadDataAsString).join("\n");
}
if (currentMessage == null || currentMessage.body == null) {
return null;
}
if (viewType == ViewType.hex) {
return currentMessage.body!.map(intToHex).join(" ");
}
final body = parent?.showDecoded == true && parent?.decoded?.text != null
? parent!.decoded!.text!
: await currentMessage.decodeBodyString();
if (viewType == ViewType.text) {
return body;
}
return _formatTextBody(viewType, body);
}
Widget _getBody(ViewType type) {
final parent = context.findAncestorStateOfType<HttpBodyState>();
final message = _effectiveMessage(parent);
if (message?.isWebSocket == true ||
(message?.contentType == ContentType.sse && message?.messages.isNotEmpty == true)) {
List<Widget>? list = message?.messages
.map((e) => Container(
margin: const EdgeInsets.only(top: 2, bottom: 2),
child: Row(
children: [
Expanded(child: Text(e.payloadDataAsString)),
const SizedBox(width: 5),
SizedBox(
width: 130,
child: SelectionContainer.disabled(
child: Text(e.time.format(), style: const TextStyle(fontSize: 12, color: Colors.grey))))
],
)))
.toList();
return Column(
children: [
const SelectionContainer.disabled(
child: Row(children: [
Expanded(child: Text("Data")),
SizedBox(width: 130, child: Text("Time")),
])),
Divider(height: 5, thickness: 1, color: Colors.grey[300]),
...list ?? []
],
);
}
if (message == null || message.body == null) {
return const SizedBox();
}
if (type == ViewType.image) {
return Center(child: Image.memory(Uint8List.fromList(message.body ?? []), fit: BoxFit.scaleDown));
}
if (type == ViewType.video) {
return const Center(child: Text("video not support preview"));
}
if (type == ViewType.hex) {
return HexViewer(data: Uint8List.fromList(message.body!), searchController: widget.searchController);
}
if (type == ViewType.formUrl) {
return HighlightTextWidget(
text: _formatTextBody(type, message.getBodyString()),
searchController: widget.searchController,
contextMenuBuilder: contextMenu);
}
return futureWidget(message.decodeBodyString(), initialData: message.getBodyString(), (body) {
try {
if (type == ViewType.jsonText) {
var jsonObject = json.decode(body);
return JsonText(
json: jsonObject,
indent: Platforms.isDesktop() ? ' ' : ' ',
colorTheme: ColorTheme.of(context),
searchController: widget.searchController,
scrollController: widget.scrollController);
}
if (type == ViewType.json) {
return JsonViewer(json.decode(body),
colorTheme: ColorTheme.of(context), searchController: widget.searchController);
}
return _buildTextBodyViewer(type, body, message: message);
} catch (e) {
logger.e(e, stackTrace: StackTrace.current);
}
return HighlightTextWidget(
text: body, searchController: widget.searchController, contextMenuBuilder: contextMenu);
});
}
String? _languageForViewType(ViewType type, HttpMessage? message) {
switch (type) {
case ViewType.html:
return 'html';
case ViewType.xml:
return 'xml';
case ViewType.css:
return 'css';
case ViewType.js:
return 'javascript';
case ViewType.json:
case ViewType.jsonText:
return 'json';
default:
return null;
}
}
Widget _buildTextBodyViewer(
ViewType type,
String text, {
HttpMessage? message,
}) {
final language = _languageForViewType(type, message);
final formattedText = language != null ? _formatTextBody(type, text) : text;
final showVirtualized = formattedText.length > _virtualizedThreshold;
if (showVirtualized) {
return VirtualizedHighlightText(
text: formattedText,
language: language,
contextMenuBuilder: contextMenu,
searchController: widget.searchController,
scrollController: widget.scrollController,
);
}
return HighlightTextWidget(
language: language,
text: formattedText,
searchController: widget.searchController,
contextMenuBuilder: contextMenu);
}
}
class Tabs {
final List<ViewType> list = [];
static Tabs of(ContentType? contentType, bool isJsonText) {
var tabs = Tabs();
if (contentType == null) {
return tabs;
}
if (contentType == ContentType.video) {
tabs.list.add(ViewType.video);
tabs.list.add(ViewType.hex);
return tabs;
}
if (contentType == ContentType.json) {
tabs.list.add(ViewType.jsonText);
}
if (contentType == ContentType.html ||
contentType == ContentType.xml ||
contentType == ContentType.js ||
contentType == ContentType.css) {
tabs.list.add(ViewType.text);
}
tabs.list.add(ViewType.of(contentType) ?? ViewType.text);
//为json时增加json格式化
if (isJsonText && !tabs.list.contains(ViewType.jsonText)) {
tabs.list.add(ViewType.jsonText);
tabs.list.add(ViewType.json);
}
if (contentType == ContentType.formUrl || contentType == ContentType.json) {
tabs.list.add(ViewType.text);
}
tabs.list.add(ViewType.hex);
return tabs;
}
List<Tab> tabList() {
return list.map((e) => Tab(text: e.title)).toList();
}
}
enum ViewType {
text("Text"),
formUrl("URL Decode"),
json("JSON"),
jsonText("JSON Text"),
html("HTML"),
xml("XML"),
image("Image"),
video("Video"),
css("CSS"),
js("JavaScript"),
hex("Hex"),
;
final String title;
const ViewType(this.title);
static ViewType? of(ContentType contentType) {
for (var value in values) {
if (value.name == contentType.name) {
return value;
}
}
return null;
}
}
class HexViewer extends StatelessWidget {
final Uint8List data;
final int bytesPerRow;
final SearchTextController searchController;
const HexViewer({super.key, required this.data, this.bytesPerRow = 16, required this.searchController});
@override
Widget build(BuildContext context) {
return HighlightTextWidget(
style: const TextStyle(fontFamily: 'Courier', fontSize: 14),
text: _formatHex(data, bytesPerRow),
searchController: searchController,
contextMenuBuilder: contextMenu);
}
String _formatHex(Uint8List data, int bytesPerRow) {
final StringBuffer buffer = StringBuffer();
for (int i = 0; i < data.length; i += bytesPerRow) {
// Address
// buffer.write(i.toRadixString(16).padLeft(8, '0'));
// buffer.write(' ');
// Hex values
for (int j = 0; j < bytesPerRow; j++) {
if (i + j < data.length) {
buffer.write(data[i + j].toRadixString(16).padLeft(2, '0'));
} else {
buffer.write(' ');
}
buffer.write(' ');
}
buffer.write(' ');
// ASCII representation
for (int j = 0; j < bytesPerRow; j++) {
if (i + j < data.length) {
final byte = data[i + j];
if (byte >= 32 && byte <= 126) {
buffer.write(String.fromCharCode(byte));
} else {
buffer.write('.');
}
}
}
buffer.writeln();
}
return buffer.toString();
}
}
class _DecodedHttpMessage extends HttpMessage {
final HttpMessage original;
final CryptoDecodedResult decoded;
_DecodedHttpMessage(this.original, this.decoded) : super(original.protocolVersion) {
headers.addAll(original.headers);
body = decoded.bytes;
}
@override
Map<String, dynamic> toJson() => original.toJson();
@override
String? get requestUrl => original.requestUrl;
}