From bd692410eccc009593d49373d21bd35818cf4349 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Tue, 9 Sep 2025 22:13:37 +0800 Subject: [PATCH] websocket preview --- lib/network/bin/server.dart | 1 + lib/ui/component/search/highlight_text.dart | 19 +- lib/ui/content/body.dart | 14 +- lib/ui/content/panel.dart | 87 +------ lib/ui/content/web_socket.dart | 248 ++++++++++++++++++++ lib/ui/desktop/desktop.dart | 2 +- lib/utils/num.dart | 2 +- 7 files changed, 271 insertions(+), 102 deletions(-) create mode 100644 lib/ui/content/web_socket.dart diff --git a/lib/network/bin/server.dart b/lib/network/bin/server.dart index 50a0906..53518aa 100644 --- a/lib/network/bin/server.dart +++ b/lib/network/bin/server.dart @@ -149,6 +149,7 @@ class ProxyServer { try { await Socket.connect('127.0.0.1', port, timeout: const Duration(milliseconds: 350)); } catch (e) { + logger.d('端口未被占用,尝试重新绑定 $port'); await restart(); } } diff --git a/lib/ui/component/search/highlight_text.dart b/lib/ui/component/search/highlight_text.dart index a745dad..254b037 100644 --- a/lib/ui/component/search/highlight_text.dart +++ b/lib/ui/component/search/highlight_text.dart @@ -3,10 +3,12 @@ import 'package:proxypin/ui/component/search/search_controller.dart'; class HighlightTextWidget extends StatelessWidget { final String text; + final TextStyle? style; final EditableTextContextMenuBuilder? contextMenuBuilder; final SearchTextController searchController; - const HighlightTextWidget({super.key, required this.text, this.contextMenuBuilder, required this.searchController}); + const HighlightTextWidget( + {super.key, required this.text, this.contextMenuBuilder, required this.searchController, this.style}); @override Widget build(BuildContext context) { @@ -25,7 +27,7 @@ class HighlightTextWidget extends StatelessWidget { List _highlightMatches(BuildContext context) { if (!searchController.shouldSearch()) { - return [TextSpan(text: text)]; + return [TextSpan(text: text, style: style)]; } final pattern = searchController.value.pattern; @@ -45,7 +47,7 @@ class HighlightTextWidget extends StatelessWidget { for (int i = 0; i < allMatches.length; i++) { final match = allMatches[i]; if (match.start > start) { - spans.add(TextSpan(text: text.substring(start, match.start))); + spans.add(TextSpan(text: text.substring(start, match.start), style: style)); } // 为每个高亮项分配一个 GlobalKey @@ -55,14 +57,14 @@ class HighlightTextWidget extends StatelessWidget { alignment: PlaceholderAlignment.middle, baseline: TextBaseline.ideographic, child: Container( - key: key, - color: i == currentIndex ? colorScheme.primary : colorScheme.inversePrimary, - child: Text(text.substring(match.start, match.end)), - ))); + key: key, + color: i == currentIndex ? colorScheme.primary : colorScheme.inversePrimary, + child: Text(text.substring(match.start, match.end), style: style), + ))); start = match.end; } if (start < text.length) { - spans.add(TextSpan(text: text.substring(start))); + spans.add(TextSpan(text: text.substring(start), style: style)); } WidgetsBinding.instance.addPostFrameCallback((_) { @@ -81,7 +83,6 @@ class HighlightTextWidget extends StatelessWidget { final key = matchKeys[currentIndex]; final context = key.currentContext; if (context != null) { - Scrollable.ensureVisible( context, duration: const Duration(milliseconds: 300), diff --git a/lib/ui/content/body.dart b/lib/ui/content/body.dart index 15b4977..ac8af04 100644 --- a/lib/ui/content/body.dart +++ b/lib/ui/content/body.dart @@ -481,7 +481,7 @@ class _BodyState extends State<_Body> { return const Center(child: Text("video not support preview")); } if (type == ViewType.hex) { - return HexViewer(data: Uint8List.fromList(message!.body!)); + return HexViewer(data: Uint8List.fromList(message!.body!), searchController: widget.searchController); } if (type == ViewType.formUrl) { @@ -590,15 +590,17 @@ enum ViewType { class HexViewer extends StatelessWidget { final Uint8List data; final int bytesPerRow; + final SearchTextController searchController; - const HexViewer({super.key, required this.data, this.bytesPerRow = 16}); + const HexViewer({super.key, required this.data, this.bytesPerRow = 16, required this.searchController}); @override Widget build(BuildContext context) { - return SelectableText( - _formatHex(data, bytesPerRow), - style: const TextStyle(fontFamily: 'Courier', fontSize: 12), - ); + return HighlightTextWidget( + style: const TextStyle(fontFamily: 'Courier', fontSize: 12), + text: _formatHex(data, bytesPerRow), + searchController: searchController, + contextMenuBuilder: contextMenu); } String _formatHex(Uint8List data, int bytesPerRow) { diff --git a/lib/ui/content/panel.dart b/lib/ui/content/panel.dart index 452a2d1..239bb06 100644 --- a/lib/ui/content/panel.dart +++ b/lib/ui/content/panel.dart @@ -14,21 +14,19 @@ * limitations under the License. */ -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:proxypin/l10n/app_localizations.dart'; import 'package:proxypin/network/bin/server.dart'; import 'package:proxypin/network/http/http.dart'; -import 'package:proxypin/network/http/websocket.dart'; import 'package:proxypin/storage/favorites.dart'; -import 'package:proxypin/ui/component/app_dialog.dart'; import 'package:proxypin/ui/component/share.dart'; import 'package:proxypin/ui/component/state_component.dart'; import 'package:proxypin/ui/component/utils.dart'; import 'package:proxypin/ui/component/widgets.dart'; import 'package:proxypin/ui/configuration.dart'; +import 'package:proxypin/ui/content/web_socket.dart'; import 'package:proxypin/ui/mobile/menu/drawer.dart'; import 'package:proxypin/ui/mobile/request/request_editor.dart'; import 'package:proxypin/ui/mobile/setting/request_map.dart'; @@ -378,87 +376,6 @@ class Cookies extends StatelessWidget { } } -///以聊天对话框样式展示websocket消息 -class Websocket extends StatelessWidget { - final ValueWrap request; - final ValueWrap response; - - const Websocket(this.request, this.response, {super.key}); - - @override - Widget build(BuildContext context) { - AppLocalizations localizations = AppLocalizations.of(context)!; - - var request = this.request.get(); - if (request == null) { - return const SizedBox(); - } - List messages = List.from(request.messages); - var response = this.response.get(); - if (response != null) { - messages.addAll(response.messages); - } - messages.sort((a, b) => a.time.compareTo(b.time)); - - return ListView.builder( - padding: const EdgeInsets.only(bottom: 15), - itemCount: messages.length, - itemBuilder: (context, index) { - var message = messages[index]; - var avatar = SelectionContainer.disabled( - child: CircleAvatar( - backgroundColor: message.isFromClient ? Colors.green : Colors.blue, - child: - Text(message.isFromClient ? 'C' : 'S', style: const TextStyle(fontSize: 18, color: Colors.white)))); - - return Padding( - padding: const EdgeInsets.only(bottom: 5), - child: Row( - mainAxisAlignment: message.isFromClient ? MainAxisAlignment.start : MainAxisAlignment.end, - children: [ - if (message.isFromClient) avatar, - const SizedBox(width: 8), - Flexible( - child: Column( - crossAxisAlignment: message.isFromClient ? CrossAxisAlignment.start : CrossAxisAlignment.end, - children: [ - SelectionContainer.disabled( - child: - Text(message.time.format(), style: const TextStyle(fontSize: 12, color: Colors.grey))), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: - message.isFromClient ? Colors.green.withOpacity(0.26) : Colors.blue.withOpacity(0.3), - borderRadius: BorderRadius.circular(10), - ), - child: SelectableText( - "${message.payloadDataAsString}${message.isBinary ? ' ${getPackage(message.payloadLength)}' : ''}", - contextMenuBuilder: (context, editableTextState) => - contextMenu(context, editableTextState, - customItem: ContextMenuButtonItem( - label: localizations.download, - onPressed: () async { - String? path = (await FilePicker.platform - .saveFile(fileName: "websocket.txt", bytes: message.payloadData)); - if (path != null && context.mounted) { - CustomToast.success(localizations.saveSuccess).show(context); - } - }, - type: ContextMenuButtonType.custom, - )), - )) - ]), - ), - const SizedBox(width: 8), - if (!message.isFromClient) avatar, - ], - )); - }, - ); - } -} - class RowWidget extends StatelessWidget { final String name; final String? value; diff --git a/lib/ui/content/web_socket.dart b/lib/ui/content/web_socket.dart new file mode 100644 index 0000000..d004d75 --- /dev/null +++ b/lib/ui/content/web_socket.dart @@ -0,0 +1,248 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:proxypin/ui/component/utils.dart'; +import 'package:proxypin/utils/lang.dart'; +import 'package:proxypin/utils/num.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../network/http/http.dart'; +import '../../network/http/websocket.dart'; +import '../../utils/platform.dart'; +import '../component/app_dialog.dart'; +import '../component/json/json_text.dart'; +import '../component/json/json_viewer.dart'; +import '../component/json/theme.dart'; + +///以聊天对话框样式展示websocket消息 +class Websocket extends StatelessWidget { + final ValueWrap request; + final ValueWrap response; + + const Websocket(this.request, this.response, {super.key}); + + @override + Widget build(BuildContext context) { + AppLocalizations localizations = AppLocalizations.of(context)!; + + var request = this.request.get(); + if (request == null) { + return const SizedBox(); + } + List messages = List.from(request.messages); + var response = this.response.get(); + if (response != null) { + messages.addAll(response.messages); + } + messages.sort((a, b) => a.time.compareTo(b.time)); + + return ListView.builder( + padding: const EdgeInsets.only(bottom: 15), + itemCount: messages.length, + itemBuilder: (context, index) { + var message = messages[index]; + var avatar = SelectionContainer.disabled( + child: CircleAvatar( + backgroundColor: message.isFromClient ? Colors.green : Colors.blue, + child: + Text(message.isFromClient ? 'C' : 'S', style: const TextStyle(fontSize: 18, color: Colors.white)))); + + var previewButton = IconButton( + tooltip: "Preview", + onPressed: () { + showDialog(context: context, builder: (context) => _PreviewDialog(bytes: message.payloadData)); + }, + icon: Icon(Icons.expand_more, color: ColorScheme.of(context).primary), + ); + + return Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Row( + mainAxisAlignment: message.isFromClient ? MainAxisAlignment.start : MainAxisAlignment.end, + children: [ + if (message.isFromClient) avatar, + const SizedBox(width: 8), + Flexible( + child: Column( + crossAxisAlignment: message.isFromClient ? CrossAxisAlignment.start : CrossAxisAlignment.end, + children: [ + SelectionContainer.disabled( + child: + Text(message.time.format(), style: const TextStyle(fontSize: 12, color: Colors.grey))), + Row(mainAxisSize: MainAxisSize.min, children: [ + if (!message.isFromClient) previewButton, + Flexible( + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: message.isFromClient + ? Colors.green.withOpacity(0.26) + : Colors.blue.withOpacity(0.3), + borderRadius: BorderRadius.circular(10), + ), + child: SelectableText( + "${message.payloadDataAsString}${message.isBinary ? ' ${getPackage(message.payloadLength)}' : ''}", + maxLines: 1, + contextMenuBuilder: (context, editableTextState) => + contextMenu(context, editableTextState, + customItem: ContextMenuButtonItem( + label: localizations.download, + onPressed: () async { + String? path = (await FilePicker.platform + .saveFile(fileName: "websocket.txt", bytes: message.payloadData)); + if (path != null && context.mounted) { + CustomToast.success(localizations.saveSuccess).show(context); + } + }, + type: ContextMenuButtonType.custom, + )), + )), + ), + if (message.isFromClient) previewButton, + ]) + ]), + ), + const SizedBox(width: 8), + if (!message.isFromClient) avatar, + ], + )); + }, + ); + } +} + +class _PreviewDialog extends StatefulWidget { + final List bytes; + + const _PreviewDialog({required this.bytes}); + + @override + State<_PreviewDialog> createState() => _PreviewDialogState(); +} + +class _PreviewDialogState extends State<_PreviewDialog> { + int tabIndex = 0; // 0: HEX, 1: TEXT + + @override + Widget build(BuildContext context) { + var tabs = [ + if (isJsonText(widget.bytes)) const Tab(text: "JSON Text"), + if (isJsonText(widget.bytes)) const Tab(text: "JSON"), + const Tab(text: "TEXT"), + const Tab(text: "HEX"), + ]; + + return AlertDialog( + content: SizedBox( + width: min(MediaQuery.of(context).size.width * 0.8, 700), + height: min(MediaQuery.of(context).size.height * 0.6, 650), + child: DefaultTabController( + length: tabs.length, + initialIndex: tabIndex, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TabBar( + tabs: tabs, + onTap: (index) { + setState(() { + tabIndex = index; + }); + }, + ), + Expanded( + child: TabBarView( + children: [ + if (isJsonText(widget.bytes)) + SingleChildScrollView(padding: const EdgeInsets.all(8.0), child: jsonText()), + if (isJsonText(widget.bytes)) + SingleChildScrollView(padding: const EdgeInsets.all(8.0), child: jsonView()), + // TEXT + SingleChildScrollView( + padding: const EdgeInsets.all(8), + child: SelectableText(safeTextPreview(widget.bytes)), + ), + + // HEX + SingleChildScrollView( + padding: const EdgeInsets.all(8), + child: SelectableText(widget.bytes.map(intToHex).join(" ")), + ), + ], + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(MaterialLocalizations.of(context).closeButtonLabel)) + ], + ); + } + + Widget jsonText() { + String body = utf8.decode(widget.bytes, allowMalformed: true); + dynamic jsonData; + try { + jsonData = json.decode(body); + } catch (e) { + jsonData = null; + } + + if (jsonData == null) { + return SelectableText(safeTextPreview(widget.bytes)); + } + + return JsonText(json: jsonData, indent: Platforms.isDesktop() ? ' ' : ' ', colorTheme: ColorTheme.of(context)); + } + + Widget jsonView() { + String body = utf8.decode(widget.bytes, allowMalformed: true); + dynamic jsonData; + try { + jsonData = json.decode(body); + } catch (e) { + jsonData = null; + } + + if (jsonData == null) { + return SelectableText(safeTextPreview(widget.bytes)); + } + + return JsonViewer(json.decode(body), colorTheme: ColorTheme.of(context)); + } + + //判断是否是json格式 + bool isJsonText(List bytes) { + return bytes.isNotEmpty && (bytes[0] == 0x7B || bytes[0] == 0x5B); + } + + /// Format bytes as hex-dump string: 4 bytes per group (8 hex digits), space between groups, line break every 16 bytes + String formatHexDump(List bytes) { + final buffer = StringBuffer(); + + // 每8个字节为一组,用空格分隔,组之间换行 + for (int i = 0; i < bytes.length; i++) { + // 添加当前十六进制部分 + buffer.write(bytes[i].toRadixString(16).padLeft(2, '0')); + if ((i + 1) % 2 == 0 && i != bytes.length - 1) { + buffer.write(' '); + } + } + return buffer.toString(); + } + + /// Decode bytes to string, non-printable as '.' + String safeTextPreview(List bytes) { + try { + return utf8.decode(bytes); + } catch (_) { + return bytes.map((b) => b >= 32 && b <= 126 ? String.fromCharCode(b) : '.').join(); + } + } +} diff --git a/lib/ui/desktop/desktop.dart b/lib/ui/desktop/desktop.dart index 1ceb934..d873099 100644 --- a/lib/ui/desktop/desktop.dart +++ b/lib/ui/desktop/desktop.dart @@ -115,7 +115,7 @@ class _DesktopHomePagePageState extends State implements EventL appBar: Tab( child: Container( padding: EdgeInsets.only(bottom: 2.5), - margin: EdgeInsets.only(bottom: 2.5), + margin: EdgeInsets.only(bottom: 5), decoration: BoxDecoration( // color: Theme.of(context).brightness == Brightness.dark ? null : Color(0xFFF9F9F9), border: Border( diff --git a/lib/utils/num.dart b/lib/utils/num.dart index bd4e668..39f0864 100644 --- a/lib/utils/num.dart +++ b/lib/utils/num.dart @@ -21,5 +21,5 @@ int hexToInt(String hex) { //int ---> hex String intToHex(int i) { - return i.toRadixString(16).toUpperCase(); + return i.toRadixString(16).padLeft(2, '0').toUpperCase(); }