Files
proxypin/lib/ui/toolbox/websocket_request.dart
2025-12-10 02:09:19 +08:00

656 lines
22 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:proxypin/l10n/app_localizations.dart';
import 'package:file_picker/file_picker.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';
/// Simple WebSocket request page: connect to ws/wss URL, send text, view messages
class WebSocketRequestPage extends StatefulWidget {
final int? windowId; // optional for desktop multi-window
const WebSocketRequestPage({super.key, this.windowId});
@override
State<WebSocketRequestPage> createState() => _WebSocketRequestPageState();
}
class _WebSocketRequestPageState extends State<WebSocketRequestPage> {
final ScrollController _scrollController = ScrollController();
bool _scrollScheduled = false;
// whether the view is considered near the bottom. If false, auto-scroll is disabled
bool _isNearBottom = true;
static const double _autoScrollThreshold = 150.0;
// key for the currently last message widget so we can ensureVisible it
final GlobalKey _lastMessageKey = GlobalKey();
// key for the input bar so we can position the jump button just above it
final GlobalKey _inputBarKey = GlobalKey();
final TextEditingController _urlController = TextEditingController(text: 'ws://');
final TextEditingController _sendController = TextEditingController();
final List<_WsMessage> _messages = [];
WebSocket? _socket;
StreamSubscription? _sub;
bool _connecting = false;
bool _connected = false;
AppLocalizations get localizations => AppLocalizations.of(context)!;
@override
void initState() {
super.initState();
// listen to scroll changes to determine whether we should auto-scroll
_scrollController.addListener(() {
if (!_scrollController.hasClients) return;
final max = _scrollController.position.maxScrollExtent;
final offset = _scrollController.offset;
final near = (max - offset) <= _autoScrollThreshold;
if (near != _isNearBottom) {
setState(() {
_isNearBottom = near;
});
}
});
}
@override
void dispose() {
_sub?.cancel();
_socket?.close();
_scrollController.dispose();
_urlController.dispose();
_sendController.dispose();
super.dispose();
}
Future<void> _connect() async {
final url = _urlController.text.trim();
if (url.isEmpty || !(url.startsWith('ws://') || url.startsWith('wss://'))) {
CustomToast.error('Invalid URL').show(context);
return;
}
setState(() {
_connecting = true;
});
try {
final socket = await WebSocket.connect(url);
_socket = socket;
_connected = true;
_connecting = false;
_listen();
setState(() {});
_addSys('Connected');
} catch (e) {
_connecting = false;
_connected = false;
setState(() {});
_addSys('Connect failed: $e');
}
}
void _listen() {
_sub?.cancel();
_sub = _socket?.listen((data) {
// data can be String or List<int>
if (data is String) {
_messages.add(_WsMessage(false, utf8.encode(data), false, time: DateTime.now()));
} else if (data is List<int>) {
_messages.add(_WsMessage(false, List<int>.from(data), true, time: DateTime.now()));
} else {
_messages.add(_WsMessage(false, utf8.encode('$data'), false, time: DateTime.now()));
}
setState(() {});
_scheduleScroll();
}, onError: (error) {
_addSys('Error: $error');
}, onDone: () {
_connected = false;
setState(() {});
_addSys('Closed');
});
}
Future<void> _disconnect() async {
try {
await _socket?.close();
} catch (_) {}
_connected = false;
setState(() {});
_addSys('Disconnected');
}
void _sendText() {
final text = _sendController.text.trim();
if (!_connected || text.isEmpty) return;
_socket?.add(text);
_messages.add(_WsMessage(true, utf8.encode(text), false, time: DateTime.now()));
_sendController.clear();
setState(() {});
_scheduleScroll();
}
Future<void> _sendFile() async {
if (!_connected) return;
try {
String? path;
if (Platforms.isMobile()) {
final result = await FilePicker.platform.pickFiles(allowMultiple: false);
if (result == null || result.files.isEmpty) return;
path = result.files.single.path;
} else {
path = path = await DesktopMultiWindow.invokeMethod(0, "pickFiles");
if (widget.windowId != null) WindowController.fromWindowId(widget.windowId!).show();
}
if (path == null) return;
final file = File(path);
final bytes = await file.readAsBytes();
if (bytes.isEmpty) return;
_socket?.add(bytes);
_messages.add(_WsMessage(true, bytes.toList(), true, time: DateTime.now()));
setState(() {});
_scheduleScroll();
if (mounted) {
CustomToast.success(AppLocalizations.of(context)!.send).show(context);
}
} catch (e) {
if (mounted) {
CustomToast.error('Send file failed: $e').show(context);
}
}
}
void _addSys(String msg) {
_messages.add(_WsMessage.system(msg));
setState(() {});
_scheduleScroll();
}
void _clearMessages() {
if (_messages.isEmpty) return;
setState(() {
_messages.clear();
});
}
String _formatTime(DateTime dt) {
final d = dt.toLocal();
String two(int n) => n.toString().padLeft(2, '0');
return '${two(d.hour)}:${two(d.minute)}:${two(d.second)}';
}
String _formatSize(int bytes) {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
double size = bytes.toDouble();
int unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return '${size.toStringAsFixed(size < 10 ? 2 : 1)} ${units[unitIndex]}';
}
void _scheduleScroll() {
// only auto-scroll when the user is already near the bottom
if (!_isNearBottom) return;
if (_scrollScheduled) return;
_scrollScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) async {
_scrollScheduled = false;
// give layout a bit more time to settle (helps when many messages are added quickly)
await Future.delayed(const Duration(milliseconds: 120));
// prefer Scrollable.ensureVisible on the last message for more natural behavior
try {
final ctx = _lastMessageKey.currentContext;
if (ctx != null) {
// use alignment slightly above bottom to avoid being hidden by input controls
await Scrollable.ensureVisible(ctx,
duration: const Duration(milliseconds: 350), curve: Curves.easeInOut, alignment: 0.9);
return;
}
} catch (_) {}
await _animateToBottom();
});
}
Future<void> _animateToBottom() async {
if (!_scrollController.hasClients) return;
final max = _scrollController.position.maxScrollExtent;
try {
await _scrollController.animateTo(max - 10, duration: Duration(milliseconds: 350), curve: Curves.easeInOut);
} catch (_) {
try {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
} catch (_) {}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Use a Stack so we can place a custom-styled "jump to latest" button
return Scaffold(
appBar: AppBar(
title: Text('WebSocket', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
centerTitle: true,
actions: [
IconButton(
tooltip: 'Clear messages',
icon: const Icon(Icons.delete),
onPressed: () => _clearMessages(),
),
SizedBox(width: 8),
]),
body: Stack(children: [
// main content
Column(children: [
Padding(
padding: const EdgeInsets.all(10),
child: Row(children: [
Expanded(
child: TextField(
controller: _urlController,
decoration: InputDecoration(labelText: 'ws(s)://', border: const OutlineInputBorder(), isDense: true),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _connecting ? null : (_connected ? _disconnect : _connect),
child: Text(_connected ? localizations.disconnect : localizations.connect)),
]),
),
const Divider(height: 0),
Expanded(child: _messageList(theme)),
const Divider(height: 0, thickness: 0.2),
Padding(
key: _inputBarKey,
padding: const EdgeInsets.all(10),
child: Row(children: [
IconButton(
icon: Icon(Icons.attach_file, color: theme.colorScheme.primary),
onPressed: _connected ? _sendFile : null,
),
const SizedBox(width: 4),
Expanded(
child: Shortcuts(
shortcuts: {
// Enter sends
SingleActivator(LogicalKeyboardKey.enter): const _SendIntent(),
// Ctrl+Enter inserts newline (also meta/cmd on macOS)
SingleActivator(LogicalKeyboardKey.enter, control: true): const _InsertNewlineIntent(),
SingleActivator(LogicalKeyboardKey.enter, meta: true): const _InsertNewlineIntent(),
},
child: Actions(
actions: {
_SendIntent: CallbackAction<_SendIntent>(onInvoke: (intent) {
if (_connected) _sendText();
return null;
}),
_InsertNewlineIntent: CallbackAction<_InsertNewlineIntent>(onInvoke: (intent) {
// Insert a newline at the current cursor position
final controller = _sendController;
final text = controller.text;
final sel = controller.selection;
final start = sel.start >= 0 ? sel.start : text.length;
final end = sel.end >= 0 ? sel.end : text.length;
final newText = text.replaceRange(start, end, '\n');
controller.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(offset: start + 1),
);
return null;
}),
},
child: TextField(
controller: _sendController,
minLines: 1,
maxLines: 4,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
decoration: InputDecoration(
labelText: localizations.requestBody,
border: const OutlineInputBorder(),
isDense: true,
),
),
),
),
),
const SizedBox(width: 8),
// Telegram-style circular send button
Tooltip(
message: localizations.send,
child: Opacity(
opacity: _connected ? 1.0 : 0.5,
child: InkWell(
onTap: _connected ? _sendText : null,
borderRadius: BorderRadius.circular(22),
child: Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2), blurRadius: 6, offset: const Offset(0, 3))
],
),
child: const Icon(Icons.send, color: Colors.white, size: 20),
),
),
),
),
]),
)
]),
// positioned jump-to-latest button (custom style). It is placed above the input area and above
// the keyboard by using MediaQuery.viewInsets.bottom as extra offset.
if (!_isNearBottom)
// pill-shaped jump button placed just above the input bar, aligned to the right
Positioned(
right: 16,
bottom: () {
final inputContext = _inputBarKey.currentContext;
final viewInsets = MediaQuery.of(context).viewInsets.bottom;
if (inputContext != null) {
final renderBox = inputContext.findRenderObject() as RenderBox?;
if (renderBox != null) {
final h = renderBox.size.height;
return (h + 12.0 + viewInsets);
}
}
return 80.0 + viewInsets;
}(),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 220),
opacity: !_isNearBottom ? 1.0 : 0.0,
child: Semantics(
label: 'Jump to latest messages',
button: true,
child: Material(
elevation: 10,
color: Colors.transparent,
child: InkWell(
onTap: () async {
await _animateToBottom();
if (!_isNearBottom) {
setState(() {
_isNearBottom = true;
});
}
},
borderRadius: BorderRadius.circular(20.0),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(20.0),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.24), blurRadius: 8, offset: const Offset(0, 4))
],
),
child: const Icon(Icons.arrow_downward, color: Colors.white, size: 18),
),
),
),
),
),
),
]),
);
}
Widget _messageList(ThemeData theme) {
// Add extra bottom padding when the jump button is visible to avoid covering content
double baseBottom = 10;
double extraBottom = 0;
if (!_isNearBottom) {
final inputContext = _inputBarKey.currentContext;
if (inputContext != null) {
final renderBox = inputContext.findRenderObject() as RenderBox?;
if (renderBox != null) {
// button height ~ 32 (pill) + margin 16; ensure some extra spacing for safe area
extraBottom = 48; // conservative spacing
}
} else {
extraBottom = 48;
}
}
return ListView.separated(
controller: _scrollController,
padding: EdgeInsets.fromLTRB(10, 10, 10, baseBottom + extraBottom),
itemBuilder: (context, index) {
final m = _messages[index];
if (m.isSystem) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SelectionContainer.disabled(
child: Text(_formatTime(m.time), style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey))),
const SizedBox(height: 4),
Text(m.textPreview(), style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
],
));
}
final displayOnLeft = !m.isClient;
final avatar = CircleAvatar(
backgroundColor: m.isClient ? Colors.green : Colors.blue,
child: Text(m.isClient ? 'C' : 'S', style: const TextStyle(color: Colors.white)),
);
final bubbleText = m.isBinary ? '[binary ${_formatSize(m.bytes.length)}]' : m.textPreview();
final bubble = Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: displayOnLeft ? Colors.green.withValues(alpha: 0.2) : Colors.blue.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: SelectableText(bubbleText),
);
final previewButton = IconButton(
onPressed: () {
showDialog(context: context, builder: (context) => _PreviewDialog(bytes: m.bytes));
},
icon: Icon(Icons.expand_more, color: ColorScheme.of(context).primary),
);
// attach key to the last message so we can ensureVisible it
final widgetKey = index == _messages.length - 1 ? _lastMessageKey : null;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
key: widgetKey,
child: Row(
mainAxisAlignment: displayOnLeft ? MainAxisAlignment.start : MainAxisAlignment.end,
children: [
if (displayOnLeft) avatar,
const SizedBox(width: 8),
Flexible(
child: Column(
crossAxisAlignment: displayOnLeft ? CrossAxisAlignment.start : CrossAxisAlignment.end,
children: [
SelectionContainer.disabled(
child:
Text(_formatTime(m.time), style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey))),
const SizedBox(height: 4),
Row(mainAxisSize: MainAxisSize.min, children: [
if (!displayOnLeft) previewButton,
Flexible(child: bubble),
if (displayOnLeft) previewButton,
]),
],
),
),
if (!displayOnLeft) const SizedBox(width: 8),
if (!displayOnLeft) avatar,
],
),
);
},
separatorBuilder: (context, index) => const SizedBox(height: 8),
itemCount: _messages.length,
);
}
}
class _WsMessage {
final bool isClient;
final List<int> bytes;
final bool isBinary;
final DateTime time;
_WsMessage(this.isClient, this.bytes, this.isBinary, {DateTime? time}) : time = time ?? DateTime.now();
bool get isSystem => bytes.isEmpty;
String textPreview() {
if (isSystem) return utf8.decode(bytes);
return utf8.decode(bytes);
}
@override
String toString() {
return 'Message(isClient: $isClient, bytes: $bytes, isBinary: $isBinary, time: $time)';
}
factory _WsMessage.system(String text) {
return _WsMessage(false, utf8.encode(text), false);
}
}
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: MediaQuery.of(context).size.width * 0.8,
height: MediaQuery.of(context).size.height * 0.6,
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: ' ', 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);
}
String intToHex(int b) => b.toRadixString(16).padLeft(2, '0');
/// 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();
}
}
}
class _SendIntent extends Intent {
const _SendIntent();
}
class _InsertNewlineIntent extends Intent {
const _InsertNewlineIntent();
}