mirror of
https://github.com/wanghongenpin/proxypin.git
synced 2026-06-01 17:15:48 +08:00
854 lines
26 KiB
Dart
854 lines
26 KiB
Dart
/*
|
||
* 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;
|
||
}
|