mirror of
https://github.com/wanghongenpin/proxypin.git
synced 2026-05-20 16:15:47 +08:00
websocket preview
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
248
lib/ui/content/web_socket.dart
Normal file
248
lib/ui/content/web_socket.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user