mirror of
https://github.com/wanghongenpin/proxypin.git
synced 2026-05-20 16:15:47 +08:00
596 lines
19 KiB
Dart
596 lines
19 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 '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:proxypin/l10n/app_localizations.dart';
|
||
import 'package:flutter_toastr/flutter_toastr.dart';
|
||
import 'package:image_pickers/image_pickers.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/toolbar/setting/request_rewrite.dart';
|
||
import 'package:proxypin/ui/mobile/setting/request_rewrite.dart';
|
||
import 'package:proxypin/utils/lang.dart';
|
||
import 'package:proxypin/utils/num.dart';
|
||
import 'package:proxypin/utils/platform.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 '../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)!;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
if (widget.windowController != null) {
|
||
HardwareKeyboard.instance.addHandler(onKeyEvent);
|
||
}
|
||
}
|
||
|
||
/// 按键事件
|
||
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;
|
||
}
|
||
|
||
@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: position.dy + renderBox.size.height + 50, 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;
|
||
|
||
var list = [
|
||
Text('$type Body', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
||
const SizedBox(width: 18),
|
||
InkWell(
|
||
key: searchIconKey,
|
||
child: Icon(Icons.search, size: 20),
|
||
// tooltip: localizations.search,
|
||
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);
|
||
}
|
||
},
|
||
),
|
||
const SizedBox(width: 5),
|
||
isImage
|
||
? downloadImageButton()
|
||
: IconButton(
|
||
visualDensity: VisualDensity.comfortable,
|
||
iconSize: 16,
|
||
icon: Icon(Icons.copy),
|
||
tooltip: localizations.copy,
|
||
onPressed: () async {
|
||
var body = await bodyKey.currentState?.getBody();
|
||
if (body == null) {
|
||
return;
|
||
}
|
||
Clipboard.setData(ClipboardData(text: body)).then((value) {
|
||
if (mounted) FlutterToastr.show(localizations.copied, context);
|
||
});
|
||
}),
|
||
];
|
||
|
||
if (!widget.hideRequestRewrite) {
|
||
list.add(IconButton(
|
||
visualDensity: VisualDensity.comfortable,
|
||
iconSize: 16,
|
||
icon: const Icon(Icons.edit_document),
|
||
tooltip: localizations.requestRewrite,
|
||
onPressed: showRequestRewrite));
|
||
}
|
||
|
||
list.add(IconButton(
|
||
visualDensity: VisualDensity.comfortable,
|
||
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);
|
||
}
|
||
}));
|
||
if (!inNewWindow) {
|
||
list.add(IconButton(
|
||
visualDensity: VisualDensity.comfortable,
|
||
iconSize: 16,
|
||
icon: const Icon(Icons.open_in_new),
|
||
tooltip: localizations.newWindow,
|
||
onPressed: () => openNew()));
|
||
}
|
||
|
||
return Wrap(crossAxisAlignment: WrapCrossAlignment.center, 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> {
|
||
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);
|
||
}
|
||
|
||
Future<String?> getBody() async {
|
||
if (message?.isWebSocket == true) {
|
||
return message?.messages.map((e) => e.payloadDataAsString).join("\n");
|
||
}
|
||
|
||
if (message == null || message?.body == null) {
|
||
return null;
|
||
}
|
||
|
||
if (viewType == ViewType.hex) {
|
||
return message!.body!.map(intToHex).join(" ");
|
||
}
|
||
|
||
try {
|
||
if (viewType == ViewType.formUrl) {
|
||
return Uri.decodeFull(message!.bodyAsString);
|
||
}
|
||
|
||
if (viewType == ViewType.jsonText || viewType == ViewType.json) {
|
||
//json格式化
|
||
var jsonObject = json.decode(await message!.decodeBodyString());
|
||
return const JsonEncoder.withIndent(" ").convert(jsonObject);
|
||
}
|
||
} catch (_) {}
|
||
return message!.decodeBodyString();
|
||
}
|
||
|
||
Widget _getBody(ViewType type) {
|
||
if (message?.isWebSocket == 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 HighlightTextWidget(
|
||
text: message!.body!.map(intToHex).join(" "),
|
||
searchController: widget.searchController,
|
||
scrollController: widget.scrollController,
|
||
contextMenuBuilder: contextMenu);
|
||
}
|
||
|
||
if (type == ViewType.formUrl) {
|
||
return HighlightTextWidget(
|
||
text: Uri.decodeFull(message!.getBodyString()),
|
||
searchController: widget.searchController,
|
||
scrollController: widget.scrollController,
|
||
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));
|
||
}
|
||
|
||
return HighlightTextWidget(
|
||
text: body,
|
||
searchController: widget.searchController,
|
||
scrollController: widget.scrollController,
|
||
contextMenuBuilder: contextMenu);
|
||
} catch (e) {
|
||
logger.e(e, stackTrace: StackTrace.current);
|
||
}
|
||
|
||
return HighlightTextWidget(
|
||
text: body,
|
||
searchController: widget.searchController,
|
||
scrollController: widget.scrollController,
|
||
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);
|
||
}
|
||
|
||
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"),
|
||
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;
|
||
}
|
||
}
|