websocket preview

This commit is contained in:
wanghongenpin
2025-09-09 22:13:37 +08:00
parent 853348f1d5
commit bd692410ec
7 changed files with 271 additions and 102 deletions

View File

@@ -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();
}
}

View File

@@ -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<InlineSpan> _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),

View File

@@ -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) {

View File

@@ -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<HttpRequest> request;
final ValueWrap<HttpResponse> 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<WebSocketFrame> 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;

View File

@@ -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<HttpRequest> request;
final ValueWrap<HttpResponse> 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<WebSocketFrame> 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<int> 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<int> 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<int> 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<int> bytes) {
try {
return utf8.decode(bytes);
} catch (_) {
return bytes.map((b) => b >= 32 && b <= 126 ? String.fromCharCode(b) : '.').join();
}
}
}

View File

@@ -115,7 +115,7 @@ class _DesktopHomePagePageState extends State<DesktopHomePage> 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(

View File

@@ -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();
}