feat: replace xterm with flutter_alacritty for true industrial-grade rendering
This commit is contained in:
@@ -1,23 +1,29 @@
|
||||
# Rtty Desktop (桌面端)
|
||||
|
||||
Rtty 的 Flutter Windows 桌面终端客户端。连接 `rtty-server`(Rust 后端),消费
|
||||
服务端下发的**原始 ANSI 二进制帧**,通过 `xterm`(Dart 版 xterm.js)做工业级
|
||||
ANSI 渲染,保证对 vim / htop / tmux 等全屏软件 100% 兼容。
|
||||
Rtty 的 Flutter Windows 桌面终端客户端。连接 `rtty-server`(Rust 后端),通过
|
||||
**`flutter_alacritty`**(Alacritty Rust 引擎的 Flutter 绑定)做 **100% 工业级
|
||||
ANSI 渲染**,完美支持 vim / neovim / htop / tmux 等全屏 TUI 应用。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
rtty-server (Rust)
|
||||
│ WebSocket (ws://host:8080/ws)
|
||||
│ • JSON 控制帧 (ready / snapshot / control / error)
|
||||
│ • 二进制帧 = 原始 ANSI 字节流 (PC 端渲染)
|
||||
rtty-server (Rust, alacritty_terminal 真相源)
|
||||
│ WebSocket (ws://host:8080/ws?client=desktop)
|
||||
│ • JSON 控制帧 (ready / error / session_closed)
|
||||
│ • 二进制帧 = 原始 ANSI 字节流
|
||||
▼
|
||||
rtty-desktop (Flutter / Windows)
|
||||
• RttyClient —— WebSocket 连接管理 + 协议解析
|
||||
• Terminal —— xterm 终端网格 / ANSI 解析
|
||||
• ConnectionBar —— 顶部连接控制条
|
||||
• RttyPtyBackend —— 实现 flutter_alacritty 的 PtyBackend 接口,桥接 WebSocket
|
||||
• TerminalEngine —— Alacritty Rust 引擎(解析 ANSI / 维护网格 / GPU 渲染)
|
||||
• TerminalView —— 终端渲染 + 输入/剪贴板/滚动
|
||||
• ConnectionBar —— 顶部连接控制条
|
||||
```
|
||||
|
||||
关键点:`PtyBackend` 抽象正是 `flutter_alacritty` 为远程(WebSocket/SSH)数据源
|
||||
预留的扩展点。本地版用 `FlutterPtyBackend`(自带 PTY),本项目用自定义
|
||||
`RttyPtyBackend` 把 WebSocket 原始 ANSI 流接入引擎,实现"远程 PTY + 本地
|
||||
alacritty 渲染",服务端与 PC 端共享同一 Alacritty 引擎。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
@@ -32,6 +38,7 @@ flutter run -d windows
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
# release 会连同 Alacritty Rust 引擎一起编译(首次较慢)
|
||||
flutter build windows --release
|
||||
# 产物:build/windows/x64/runner/Release/rtty_desktop.exe
|
||||
```
|
||||
@@ -42,14 +49,15 @@ flutter build windows --release
|
||||
# Widget 测试(无需服务端)
|
||||
flutter test test/widget_test.dart
|
||||
|
||||
# 端到端集成测试(需先启动 Rust 服务端)
|
||||
# 端到端集成测试(需先启动 Rust 服务端;离线时自动跳过)
|
||||
flutter test test/e2e_ws_test.dart
|
||||
flutter test test/regression_ws_test.dart
|
||||
```
|
||||
|
||||
## 协议要点
|
||||
|
||||
- 连接 `ws://host:port/ws`(可选 `?session=<id>` 复用会话)。
|
||||
- 服务端 `ready` 帧携带会话 ID 与初始尺寸,驱动 xterm 网格。
|
||||
- PTY 输出以二进制帧下发,桌面端直接喂给 xterm 渲染。
|
||||
- 键盘输入与终端回写经 `{"type":"input","data":...}` 发回服务端写入 PTY。
|
||||
- 窗口尺寸变化时发送 `{"type":"resize","cols":..,"rows":..}` 同步网格。
|
||||
- 连接 `ws://host:port/ws?client=desktop`(`client` 声明端类型,实现多端解耦)。
|
||||
- 服务端 `ready` 帧携带会话 ID 与初始尺寸,驱动引擎网格。
|
||||
- PTY 输出以二进制帧下发,经 `RttyPtyBackend.output` 喂给引擎渲染。
|
||||
- 键盘输入由引擎产生,经 `RttyPtyBackend.write` 发回服务端写入远端 PTY。
|
||||
- 窗口尺寸变化经 `TerminalView.onPtyResize` → `RttyPtyBackend.resize` 同步。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'rtty_client.dart';
|
||||
import 'rtty_pty_backend.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// 顶部连接控制条。
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
/// 连接状态。
|
||||
enum ConnState { disconnected, connecting, connected, error }
|
||||
|
||||
/// WebSocket 终端客户端。
|
||||
///
|
||||
/// 负责与 Rtty 服务端建立连接:
|
||||
/// - 消费服务端的 JSON 控制帧(ready / snapshot / control / error / session_closed);
|
||||
/// - 把 PTY 输出的原始 ANSI 二进制帧交给 [Terminal] 渲染;
|
||||
/// - 把终端产生的输出(用户键入 + 控制序列响应)以 `input` 消息发回服务端。
|
||||
class RttyClient {
|
||||
RttyClient(this.terminal) {
|
||||
// xterm 需要把终端输出(键入、控制序列响应)回传给后端。
|
||||
terminal.onOutput = _handleTerminalOutput;
|
||||
}
|
||||
|
||||
final Terminal terminal;
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _sub;
|
||||
|
||||
ConnState _state = ConnState.disconnected;
|
||||
ConnState get state => _state;
|
||||
|
||||
String? _sessionId;
|
||||
String? get sessionId => _sessionId;
|
||||
|
||||
String? _error;
|
||||
String? get error => _error;
|
||||
|
||||
bool get isConnected => _state == ConnState.connected;
|
||||
|
||||
/// 状态变化回调。
|
||||
void Function(ConnState state)? onStateChanged;
|
||||
|
||||
/// 就绪回调(收到 ready 帧,携带会话 ID 与初始尺寸)。
|
||||
void Function(String id, int cols, int rows)? onReady;
|
||||
|
||||
/// 会话结束回调。
|
||||
void Function()? onSessionClosed;
|
||||
|
||||
/// 原始 ANSI 数据回调(用于调试 / 集成测试)。
|
||||
void Function(String data)? onRawData;
|
||||
|
||||
/// 连接到服务端。
|
||||
Future<void> connect({
|
||||
required String host,
|
||||
required int port,
|
||||
String? session,
|
||||
}) async {
|
||||
await disconnect();
|
||||
|
||||
_setState(ConnState.connecting);
|
||||
_error = null;
|
||||
|
||||
final query = session != null && session.isNotEmpty
|
||||
? '?session=${Uri.encodeQueryComponent(session)}'
|
||||
: '';
|
||||
final uri = Uri.parse('ws://$host:$port/ws$query');
|
||||
|
||||
try {
|
||||
_channel = IOWebSocketChannel.connect(uri);
|
||||
_sub = _channel!.stream.listen(
|
||||
_handleIncoming,
|
||||
onError: (Object e) {
|
||||
_error = e.toString();
|
||||
_setState(ConnState.error);
|
||||
},
|
||||
onDone: () {
|
||||
if (_state == ConnState.connected) {
|
||||
_setState(ConnState.disconnected);
|
||||
}
|
||||
},
|
||||
);
|
||||
_setState(ConnState.connected);
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
_setState(ConnState.error);
|
||||
}
|
||||
}
|
||||
|
||||
/// 断开连接。
|
||||
Future<void> disconnect() async {
|
||||
await _sub?.cancel();
|
||||
_sub = null;
|
||||
await _channel?.sink.close();
|
||||
_channel = null;
|
||||
_sessionId = null;
|
||||
if (_state != ConnState.disconnected) {
|
||||
_setState(ConnState.disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleIncoming(dynamic message) {
|
||||
if (message is List<int>) {
|
||||
// PC 端原始 ANSI 字节流。
|
||||
final text = utf8.decode(message, allowMalformed: true);
|
||||
onRawData?.call(text);
|
||||
terminal.write(text);
|
||||
} else if (message is String) {
|
||||
_handleJsonFrame(message);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleJsonFrame(String text) {
|
||||
final Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(text);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (decoded is! Map<String, dynamic>) return;
|
||||
|
||||
switch (decoded['type']) {
|
||||
case 'ready':
|
||||
final id = decoded['id'] as String? ?? '';
|
||||
final cols = (decoded['cols'] as num?)?.toInt() ?? terminal.viewWidth;
|
||||
final rows = (decoded['rows'] as num?)?.toInt() ?? terminal.viewHeight;
|
||||
_sessionId = id;
|
||||
_setState(ConnState.connected);
|
||||
onReady?.call(id, cols, rows);
|
||||
case 'mobile_snapshot':
|
||||
// 桌面端使用原始 ANSI 渲染,忽略语义化快照。
|
||||
break;
|
||||
case 'control_response':
|
||||
case 'error':
|
||||
case 'session_closed':
|
||||
onSessionClosed?.call();
|
||||
_setState(ConnState.disconnected);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// xterm 需要把终端输出回传给后端。
|
||||
void _handleTerminalOutput(String data) {
|
||||
if (!isConnected || _channel == null) return;
|
||||
final frame = jsonEncode({'type': 'input', 'data': data});
|
||||
_channel!.sink.add(frame);
|
||||
}
|
||||
|
||||
/// 发送 resize 指令。
|
||||
void sendResize(int cols, int rows) {
|
||||
if (!isConnected || _channel == null) return;
|
||||
final frame = jsonEncode({'type': 'resize', 'cols': cols, 'rows': rows});
|
||||
_channel!.sink.add(frame);
|
||||
}
|
||||
|
||||
/// 发送原始输入字节到服务端(写入 PTY)。
|
||||
void sendInput(String data) {
|
||||
if (!isConnected || _channel == null) return;
|
||||
final frame = jsonEncode({'type': 'input', 'data': data});
|
||||
_channel!.sink.add(frame);
|
||||
}
|
||||
|
||||
void _setState(ConnState state) {
|
||||
if (_state == state) return;
|
||||
_state = state;
|
||||
onStateChanged?.call(state);
|
||||
}
|
||||
}
|
||||
169
desktop/lib/src/rtty_pty_backend.dart
Normal file
169
desktop/lib/src/rtty_pty_backend.dart
Normal file
@@ -0,0 +1,169 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_alacritty/flutter_alacritty.dart';
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
/// 连接状态。
|
||||
enum ConnState { disconnected, connecting, connected, error }
|
||||
|
||||
/// 将 Rtty Rust 服务端桥接为 flutter_alacritty 的 [PtyBackend]。
|
||||
///
|
||||
/// 这正是该库为远程(WebSocket/SSH)数据源预留的扩展点:
|
||||
/// - [output]:把 WebSocket 二进制帧(服务端原始 ANSI)作为 PTY 输出流;
|
||||
/// - [write]:把引擎产生的输入字节发回服务端写入远端 PTY;
|
||||
/// - [resize]:同步终端尺寸到服务端;
|
||||
/// - [exitCode]:服务端会话结束时完成。
|
||||
class RttyPtyBackend implements PtyBackend {
|
||||
RttyPtyBackend();
|
||||
|
||||
final StreamController<Uint8List> _outputCtl =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
final Completer<int> _exitCodeCtl = Completer<int>();
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _sub;
|
||||
|
||||
ConnState _state = ConnState.disconnected;
|
||||
ConnState get state => _state;
|
||||
|
||||
String? _sessionId;
|
||||
String? get sessionId => _sessionId;
|
||||
|
||||
String? _error;
|
||||
String? get error => _error;
|
||||
|
||||
/// 就绪回调(收到 ready 帧,携带会话 ID 与初始尺寸)。
|
||||
void Function(String id, int cols, int rows)? onReady;
|
||||
|
||||
/// 状态变化回调。
|
||||
void Function(ConnState state)? onStateChanged;
|
||||
|
||||
@override
|
||||
Stream<Uint8List> get output => _outputCtl.stream;
|
||||
|
||||
@override
|
||||
Future<int> get exitCode => _exitCodeCtl.future;
|
||||
|
||||
@override
|
||||
ValueListenable<bool>? get isForegroundProcessRunning => null;
|
||||
|
||||
/// 连接到 Rtty 服务端。
|
||||
Future<void> connect({
|
||||
required String host,
|
||||
required int port,
|
||||
String? session,
|
||||
}) async {
|
||||
await close();
|
||||
|
||||
_setState(ConnState.connecting);
|
||||
_error = null;
|
||||
|
||||
final query = (session != null && session.isNotEmpty)
|
||||
? '?session=${Uri.encodeQueryComponent(session)}&client=desktop'
|
||||
: '?client=desktop';
|
||||
final uri = Uri.parse('ws://$host:$port/ws$query');
|
||||
|
||||
try {
|
||||
_channel = IOWebSocketChannel.connect(uri);
|
||||
_sub = _channel!.stream.listen(
|
||||
_handleIncoming,
|
||||
onError: (Object e) {
|
||||
_error = e.toString();
|
||||
_setState(ConnState.error);
|
||||
},
|
||||
onDone: () {
|
||||
if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(0);
|
||||
if (_state == ConnState.connected) _setState(ConnState.disconnected);
|
||||
},
|
||||
);
|
||||
_setState(ConnState.connected);
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(1);
|
||||
_setState(ConnState.error);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleIncoming(dynamic message) {
|
||||
if (message is List<int>) {
|
||||
// PC 端原始 ANSI 字节流 → 喂给引擎渲染。
|
||||
_outputCtl.add(Uint8List.fromList(message));
|
||||
} else if (message is String) {
|
||||
_handleJsonFrame(message);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleJsonFrame(String text) {
|
||||
final Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(text);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (decoded is! Map<String, dynamic>) return;
|
||||
|
||||
switch (decoded['type']) {
|
||||
case 'ready':
|
||||
_sessionId = decoded['id'] as String? ?? '';
|
||||
final cols = (decoded['cols'] as num?)?.toInt() ?? 80;
|
||||
final rows = (decoded['rows'] as num?)?.toInt() ?? 24;
|
||||
_setState(ConnState.connected);
|
||||
onReady?.call(_sessionId ?? '', cols, rows);
|
||||
case 'mobile_snapshot':
|
||||
// 桌面端使用原始 ANSI 渲染,忽略语义化快照。
|
||||
break;
|
||||
case 'error':
|
||||
case 'session_closed':
|
||||
if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(0);
|
||||
_setState(ConnState.disconnected);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void write(Uint8List data) {
|
||||
if (_state != ConnState.connected || _channel == null) return;
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': 'input',
|
||||
'data': utf8.decode(data, allowMalformed: true),
|
||||
}));
|
||||
}
|
||||
|
||||
@override
|
||||
void resize(int rows, int columns) {
|
||||
if (_state != ConnState.connected || _channel == null) return;
|
||||
_channel!.sink
|
||||
.add(jsonEncode({'type': 'resize', 'cols': columns, 'rows': rows}));
|
||||
}
|
||||
|
||||
@override
|
||||
void kill() {
|
||||
close();
|
||||
}
|
||||
|
||||
/// 关闭连接并释放资源。
|
||||
Future<void> close() async {
|
||||
await _sub?.cancel();
|
||||
_sub = null;
|
||||
await _channel?.sink.close();
|
||||
_channel = null;
|
||||
_sessionId = null;
|
||||
if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(0);
|
||||
if (_state != ConnState.disconnected) _setState(ConnState.disconnected);
|
||||
}
|
||||
|
||||
void _setState(ConnState state) {
|
||||
if (_state == state) return;
|
||||
_state = state;
|
||||
onStateChanged?.call(state);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
close();
|
||||
_outputCtl.close();
|
||||
}
|
||||
}
|
||||
50
desktop/lib/src/terminal_config.dart
Normal file
50
desktop/lib/src/terminal_config.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter_alacritty/flutter_alacritty.dart';
|
||||
|
||||
/// 将 [RttyTheme] 的工业终端配色映射为 flutter_alacritty 的 [TerminalConfig]。
|
||||
class RttyTerminalConfig {
|
||||
RttyTerminalConfig._();
|
||||
|
||||
static TerminalConfig build() {
|
||||
final defaults = TerminalConfig.defaults();
|
||||
return defaults.copyWith(
|
||||
colors: const TerminalColors(
|
||||
background: 0xFF0B0E11,
|
||||
foreground: 0xFFCBD5DF,
|
||||
selection: 0x553A6EA5,
|
||||
ansi: [
|
||||
0xFF0B0E11, // black
|
||||
0xFFE5534B, // red
|
||||
0xFF3DDC97, // green
|
||||
0xFFFFB454, // yellow
|
||||
0xFF5AB0FF, // blue
|
||||
0xFFD77BFF, // magenta
|
||||
0xFF4ED6E0, // cyan
|
||||
0xFFDDE6EE, // white
|
||||
0xFF4A5866, // bright black
|
||||
0xFFFF6B6B, // bright red
|
||||
0xFF6BFFC2, // bright green
|
||||
0xFFFFCE7A, // bright yellow
|
||||
0xFF82C4FF, // bright blue
|
||||
0xFFE8A6FF, // bright magenta
|
||||
0xFF82EDF4, // bright cyan
|
||||
0xFFFFFDF8, // bright white
|
||||
],
|
||||
searchMatchBg: 0xFFB45454,
|
||||
searchMatchFg: 0xFF14100A,
|
||||
searchFocusedBg: 0xFFFFB454,
|
||||
searchFocusedFg: 0xFF14100A,
|
||||
hintStartFg: 0xFF14100A,
|
||||
hintStartBg: 0xFFFFB454,
|
||||
cursorText: 0xFF06130D,
|
||||
cursorBody: 0xFF3DDC97,
|
||||
),
|
||||
font: const FontConfig(
|
||||
family: 'Consolas',
|
||||
fallback: ['Cascadia Mono', 'JetBrains Mono', 'Menlo', 'monospace'],
|
||||
size: 14.0,
|
||||
lineHeight: 1.15,
|
||||
),
|
||||
scrolling: defaults.scrolling.copyWith(history: 10000),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
import 'package:flutter_alacritty/flutter_alacritty.dart';
|
||||
|
||||
import 'connection_bar.dart';
|
||||
import 'rtty_client.dart';
|
||||
import 'rtty_pty_backend.dart';
|
||||
import 'terminal_config.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// 主终端页面。
|
||||
///
|
||||
/// 组织连接控制条 + xterm 终端渲染区 + 底部状态栏。
|
||||
/// 断开时展示终端提示页,连接后进入实时渲染。
|
||||
/// 使用 `flutter_alacritty` 的 Rust 引擎(TerminalEngine + TerminalView)渲染,
|
||||
/// 通过 [RttyPtyBackend] 桥接远程 Rtty Rust 服务端的 WebSocket 数据流。
|
||||
/// 断开时展示终端提示页,连接后进入实时工业级 ANSI 渲染。
|
||||
class TerminalScreen extends StatefulWidget {
|
||||
const TerminalScreen({super.key});
|
||||
|
||||
@@ -17,8 +19,12 @@ class TerminalScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TerminalScreenState extends State<TerminalScreen> {
|
||||
late final Terminal _terminal;
|
||||
late final RttyClient _client;
|
||||
late final TerminalConfig _config;
|
||||
TerminalEngine? _engine;
|
||||
TerminalController _controller = TerminalController();
|
||||
final FocusNode _focus = FocusNode();
|
||||
|
||||
RttyPtyBackend? _backend;
|
||||
|
||||
final _host = TextEditingController(text: '127.0.0.1');
|
||||
final _port = TextEditingController(text: '8080');
|
||||
@@ -32,32 +38,17 @@ class _TerminalScreenState extends State<TerminalScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_terminal = Terminal(onOutput: (_) {});
|
||||
_client = RttyClient(_terminal);
|
||||
_client.onStateChanged = (s) => setState(() => _state = s);
|
||||
_client.onReady = (id, cols, rows) {
|
||||
setState(() {
|
||||
_sessionId = id;
|
||||
_cols = cols;
|
||||
_rows = rows;
|
||||
});
|
||||
};
|
||||
_client.onSessionClosed = () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_state = ConnState.disconnected;
|
||||
_sessionId = null;
|
||||
});
|
||||
}
|
||||
};
|
||||
_config = RttyTerminalConfig.build();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_client.disconnect();
|
||||
_tearDownSession();
|
||||
_host.dispose();
|
||||
_port.dispose();
|
||||
_session.dispose();
|
||||
_controller.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -67,17 +58,65 @@ class _TerminalScreenState extends State<TerminalScreen> {
|
||||
final session = _session.text.trim();
|
||||
if (host.isEmpty) return;
|
||||
|
||||
_terminal.write('\x1b[2J\x1b[H');
|
||||
await _client.connect(host: host, port: port, session: session);
|
||||
_tearDownSession();
|
||||
|
||||
setState(() {
|
||||
_engine = TerminalEngine(config: _config);
|
||||
_controller = TerminalController()..attach(_engine!);
|
||||
_backend = RttyPtyBackend();
|
||||
});
|
||||
|
||||
_backend!.onStateChanged = (s) {
|
||||
if (!mounted) return;
|
||||
setState(() => _state = s);
|
||||
if (s == ConnState.disconnected || s == ConnState.error) {
|
||||
_sessionId = null;
|
||||
}
|
||||
};
|
||||
_backend!.onReady = (id, cols, rows) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_sessionId = id;
|
||||
_cols = cols;
|
||||
_rows = rows;
|
||||
});
|
||||
};
|
||||
|
||||
await _backend!.connect(host: host, port: port, session: session);
|
||||
}
|
||||
|
||||
Future<void> _disconnect() => _client.disconnect();
|
||||
void _disconnect() => _tearDownSession();
|
||||
|
||||
void _tearDownSession() {
|
||||
final b = _backend;
|
||||
if (b != null) {
|
||||
b.onStateChanged = null;
|
||||
b.onReady = null;
|
||||
b.kill();
|
||||
b.dispose();
|
||||
}
|
||||
_backend = null;
|
||||
_engine?.dispose();
|
||||
_engine = null;
|
||||
_sessionId = null;
|
||||
_state = ConnState.disconnected;
|
||||
}
|
||||
|
||||
void _handlePtyResize(int cols, int rows) {
|
||||
final b = _backend;
|
||||
if (b == null) return;
|
||||
b.resize(rows, cols);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_cols = cols;
|
||||
_rows = rows;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sizeText = _state == ConnState.connected
|
||||
? '$_cols × $_rows'
|
||||
: '—';
|
||||
final sizeText = _state == ConnState.connected ? '$_cols × $_rows' : '—';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: RttyTheme.background,
|
||||
@@ -101,92 +140,36 @@ class _TerminalScreenState extends State<TerminalScreen> {
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_state == ConnState.connected) {
|
||||
return _TerminalViewport(
|
||||
terminal: _terminal,
|
||||
cols: _cols,
|
||||
rows: _rows,
|
||||
onResize: _handleResize,
|
||||
final engine = _engine;
|
||||
if (_state == ConnState.connected && engine != null) {
|
||||
return Container(
|
||||
color: RttyTheme.background,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: RttyTheme.background,
|
||||
border: Border.all(color: RttyTheme.border),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
child: TerminalView(
|
||||
engine,
|
||||
controller: _controller,
|
||||
focusNode: _focus,
|
||||
autofocus: true,
|
||||
onPtyResize: _handlePtyResize,
|
||||
theme: _config.theme,
|
||||
textStyle: _config.style,
|
||||
mouseCursor: SystemMouseCursors.text,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _IdlePane(state: _state);
|
||||
}
|
||||
|
||||
void _handleResize(int cols, int rows) {
|
||||
setState(() {
|
||||
_cols = cols;
|
||||
_rows = rows;
|
||||
});
|
||||
_client.sendResize(cols, rows);
|
||||
}
|
||||
}
|
||||
|
||||
/// 终端渲染视口。监听尺寸变化并同步给服务端。
|
||||
class _TerminalViewport extends StatefulWidget {
|
||||
const _TerminalViewport({
|
||||
required this.terminal,
|
||||
required this.cols,
|
||||
required this.rows,
|
||||
required this.onResize,
|
||||
});
|
||||
|
||||
final Terminal terminal;
|
||||
final int cols;
|
||||
final int rows;
|
||||
final void Function(int cols, int rows) onResize;
|
||||
|
||||
@override
|
||||
State<_TerminalViewport> createState() => _TerminalViewportState();
|
||||
}
|
||||
|
||||
class _TerminalViewportState extends State<_TerminalViewport> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 估算每格宽高,把像素尺寸转成网格尺寸并触发 resize。
|
||||
final style = RttyTheme.terminalStyle();
|
||||
final fontSize = style.fontSize;
|
||||
final colWidth = fontSize * 0.62;
|
||||
final rowHeight = fontSize * style.height;
|
||||
|
||||
final cols = (constraints.maxWidth / colWidth).floor().clamp(20, 500);
|
||||
final rows =
|
||||
(constraints.maxHeight / rowHeight).floor().clamp(5, 300);
|
||||
|
||||
// 网格变化时通知服务端。
|
||||
if (cols != widget.cols || rows != widget.rows) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.terminal.resize(cols, rows);
|
||||
widget.onResize(cols, rows);
|
||||
});
|
||||
}
|
||||
|
||||
return Container(
|
||||
color: RttyTheme.background,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: RttyTheme.background,
|
||||
border: Border.all(color: RttyTheme.border),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
child: TerminalView(
|
||||
widget.terminal,
|
||||
theme: RttyTheme.terminal(),
|
||||
textStyle: style,
|
||||
autofocus: true,
|
||||
mouseCursor: SystemMouseCursors.text,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 未连接时的提示面板。
|
||||
@@ -325,7 +308,7 @@ class _StatusBar extends StatelessWidget {
|
||||
_Hint('Ctrl+Shift+C / V 复制粘贴', RttyTheme.textFaint),
|
||||
Spacer(),
|
||||
Text(
|
||||
'FLUTTER × XTERM TERMINAL',
|
||||
'FLUTTER × ALACRITTY ENGINE',
|
||||
style: TextStyle(color: RttyTheme.textFaint, fontSize: 10),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
/// Rtty 桌面端主题。
|
||||
///
|
||||
@@ -44,39 +43,4 @@ class RttyTheme {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// xterm 终端主题。
|
||||
static TerminalTheme terminal() => TerminalTheme(
|
||||
cursor: primary,
|
||||
selection: const Color(0xFF2A5566).withValues(alpha: 0.45),
|
||||
searchHitBackground: accent.withValues(alpha: 0.25),
|
||||
searchHitBackgroundCurrent: accent,
|
||||
searchHitForeground: const Color(0xFF14100A),
|
||||
foreground: const Color(0xFFCBD5DF),
|
||||
background: const Color(0xFF0B0E11),
|
||||
black: const Color(0xFF0B0E11),
|
||||
white: const Color(0xFFDDE6EE),
|
||||
red: const Color(0xFFE5534B),
|
||||
green: const Color(0xFF3DDC97),
|
||||
yellow: const Color(0xFFFFB454),
|
||||
blue: const Color(0xFF5AB0FF),
|
||||
magenta: const Color(0xFFD77BFF),
|
||||
cyan: const Color(0xFF4ED6E0),
|
||||
brightBlack: const Color(0xFF4A5866),
|
||||
brightRed: const Color(0xFFFF6B6B),
|
||||
brightGreen: const Color(0xFF6BFFC2),
|
||||
brightYellow: const Color(0xFFFFCE7A),
|
||||
brightBlue: const Color(0xFF82C4FF),
|
||||
brightMagenta: const Color(0xFFE8A6FF),
|
||||
brightCyan: const Color(0xFF82EDF4),
|
||||
brightWhite: const Color(0xFFFFFDF8),
|
||||
);
|
||||
|
||||
/// xterm 终端字体。
|
||||
static TerminalStyle terminalStyle() => const TerminalStyle(
|
||||
fontFamily: 'Consolas',
|
||||
fontFamilyFallback: ['Cascadia Mono', 'JetBrains Mono', 'Menlo'],
|
||||
fontSize: 14,
|
||||
height: 1.15,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
adaptive_number:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: adaptive_number
|
||||
sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -17,6 +33,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
build_cli_annotations:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_cli_annotations
|
||||
sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -33,6 +57,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -49,6 +81,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.3.5+4"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -65,14 +105,22 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
equatable:
|
||||
desktop_drop:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2"
|
||||
name: desktop_drop
|
||||
sha256: aa1e797255bfbc76f9eb5aa4f61e5b68dbf69962ab1be6495816d2f251bc0d1f
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
version: "0.7.1"
|
||||
ed25519_edwards:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ed25519_edwards
|
||||
sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -81,11 +129,35 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_alacritty:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_alacritty
|
||||
sha256: "0a4eaaecf09fd86ac3d6bf6cc9b82ace49ba888799fc27934982baedd8512c2d"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -94,11 +166,96 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_pty_new:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_pty_new
|
||||
sha256: "8894d9e7b4e85af5d6c080bc18d14956a0023e8394f96e81e8ab5c3528e7a0c2"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
flutter_rust_bridge:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_rust_bridge
|
||||
sha256: e87d6b9ee934dcd24a128ccb2bd91905d2d5fe5c06245d6a8f5477d4907a437a
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.12.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
freezed_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: freezed_annotation
|
||||
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
jni_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
jni_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_util
|
||||
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.12.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -131,6 +288,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -155,6 +320,22 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "9.5.0"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -163,14 +344,102 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
quiver:
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: quiver
|
||||
sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2
|
||||
name: path_provider
|
||||
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
version: "2.1.6"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.2"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
record_use:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
rust_lib_flutter_alacritty:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: rust_lib_flutter_alacritty
|
||||
sha256: "258f038b57e2c9548ab901624971f8dd05973f07858f7662158504c72a9dd7b6"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -224,6 +493,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
toml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: toml
|
||||
sha256: "35a35f782228656a2af31e8c73d1353cc4ef3d683fd68af1111b44631879c05e"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.18.0"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -232,6 +509,78 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
universal_platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: universal_platform
|
||||
sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
url_launcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.3.32"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.2.5"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -272,22 +621,22 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
xterm:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: xterm
|
||||
sha256: "168dfedca77cba33fdb6f52e2cd001e9fde216e398e89335c19b524bb22da3a2"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
zmodem:
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: zmodem
|
||||
sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf"
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.0.6"
|
||||
version: "1.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.12.2 <4.0.0"
|
||||
flutter: ">=3.19.0"
|
||||
flutter: ">=3.44.0"
|
||||
|
||||
@@ -35,7 +35,7 @@ dependencies:
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
web_socket_channel: ^3.0.3
|
||||
xterm: ^4.0.0
|
||||
flutter_alacritty: ^2.4.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -1,49 +1,51 @@
|
||||
// 端到端集成测试:桌面端连接层 ↔ Rust 服务端。
|
||||
// 端到端集成测试:桌面端连接层(RttyPtyBackend)↔ Rust 服务端。
|
||||
//
|
||||
// 运行前需先启动 Rust 服务端:cd .. && cargo run
|
||||
// 运行:flutter test test/e2e_ws_test.dart
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:rtty_desktop/src/rtty_client.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:rtty_desktop/src/rtty_pty_backend.dart';
|
||||
|
||||
import 'support.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('RttyClient connects to Rust server and renders command output',
|
||||
test('RttyPtyBackend connects to Rust server and receives raw ANSI output',
|
||||
() async {
|
||||
if (!await isServerUp('127.0.0.1', 8080)) {
|
||||
markTestSkipped('Rtty server not running');
|
||||
return;
|
||||
}
|
||||
final terminal = Terminal();
|
||||
final client = RttyClient(terminal);
|
||||
final backend = RttyPtyBackend();
|
||||
|
||||
final states = <ConnState>[];
|
||||
String? sessionId;
|
||||
final raw = StringBuffer();
|
||||
client.onStateChanged = (s) => states.add(s);
|
||||
client.onReady = (id, cols, rows) => sessionId = '$id:$cols:$rows';
|
||||
client.onRawData = (d) => raw.write(d);
|
||||
backend.onStateChanged = (s) => states.add(s);
|
||||
backend.onReady = (id, cols, rows) => sessionId = '$id:$cols:$rows';
|
||||
backend.output.listen((Uint8List b) {
|
||||
raw.write(String.fromCharCodes(b));
|
||||
});
|
||||
|
||||
await client.connect(host: '127.0.0.1', port: 8080);
|
||||
await backend.connect(host: '127.0.0.1', port: 8080);
|
||||
|
||||
// 等待连接就绪(socket 已连接 + 收到 ready 帧)。
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while ((!client.isConnected || sessionId == null) &&
|
||||
while ((backend.state != ConnState.connected || sessionId == null) &&
|
||||
DateTime.now().isBefore(deadline)) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
expect(client.isConnected, isTrue,
|
||||
expect(backend.state, ConnState.connected,
|
||||
reason: '未能连接服务端 states=$states');
|
||||
expect(sessionId, isNotNull,
|
||||
reason: '未收到 ready 帧 states=$states');
|
||||
expect(sessionId, isNotNull, reason: '未收到 ready 帧 states=$states');
|
||||
|
||||
// 发送命令,等待回显。
|
||||
client.sendInput('echo RTTY_E2E_${DateTime.now().millisecondsSinceEpoch}\r\n');
|
||||
backend.write(Uint8List.fromList(
|
||||
'echo RTTY_E2E_${DateTime.now().millisecondsSinceEpoch}\r\n'.codeUnits));
|
||||
|
||||
String? matchedLine;
|
||||
final outDeadline = DateTime.now().add(const Duration(seconds: 8));
|
||||
@@ -57,9 +59,9 @@ void main() {
|
||||
|
||||
expect(matchedLine, isNotNull,
|
||||
reason: '未捕获到命令回显。流末尾:'
|
||||
'${raw.toString().split('').length > 200 ? raw.toString().substring(raw.toString().length - 200) : raw.toString()}');
|
||||
'${raw.toString().length > 200 ? raw.toString().substring(raw.toString().length - 200) : raw.toString()}');
|
||||
|
||||
await client.disconnect();
|
||||
expect(client.state, ConnState.disconnected);
|
||||
await backend.close();
|
||||
expect(backend.state, ConnState.disconnected);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <desktop_drop/desktop_drop_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
DesktopDropPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("DesktopDropPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
desktop_drop
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
flutter_pty_new
|
||||
jni
|
||||
rust_lib_flutter_alacritty
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
Reference in New Issue
Block a user