feat: replace xterm with flutter_alacritty for true industrial-grade rendering

This commit is contained in:
2026-08-01 19:53:30 +08:00
parent 7e4236e2f3
commit 562662c1bd
12 changed files with 744 additions and 375 deletions

View File

@@ -1,23 +1,29 @@
# Rtty Desktop (桌面端) # Rtty Desktop (桌面端)
Rtty 的 Flutter Windows 桌面终端客户端。连接 `rtty-server`Rust 后端),消费 Rtty 的 Flutter Windows 桌面终端客户端。连接 `rtty-server`Rust 后端),通过
服务端下发的**原始 ANSI 二进制帧**,通过 `xterm`Dart 版 xterm.js工业级 **`flutter_alacritty`**Alacritty Rust 引擎的 Flutter 绑定)做 **100% 工业级
ANSI 渲染,保证对 vim / htop / tmux 等全屏软件 100% 兼容 ANSI 渲染**,完美支持 vim / neovim / htop / tmux 等全屏 TUI 应用
## 架构 ## 架构
``` ```
rtty-server (Rust) rtty-server (Rust, alacritty_terminal 真相源)
│ WebSocket (ws://host:8080/ws) │ WebSocket (ws://host:8080/ws?client=desktop)
│ • JSON 控制帧 (ready / snapshot / control / error) │ • JSON 控制帧 (ready / error / session_closed)
│ • 二进制帧 = 原始 ANSI 字节流 (PC 端渲染) │ • 二进制帧 = 原始 ANSI 字节流
rtty-desktop (Flutter / Windows) rtty-desktop (Flutter / Windows)
• RttyClient —— WebSocket 连接管理 + 协议解析 • RttyPtyBackend —— 实现 flutter_alacritty 的 PtyBackend 接口,桥接 WebSocket
• Terminal —— xterm 终端网格 / ANSI 解析 • TerminalEngine —— Alacritty Rust 引擎(解析 ANSI / 维护网格 / GPU 渲染)
ConnectionBar —— 顶部连接控制条 TerminalView —— 终端渲染 + 输入/剪贴板/滚动
• ConnectionBar —— 顶部连接控制条
``` ```
关键点:`PtyBackend` 抽象正是 `flutter_alacritty` 为远程WebSocket/SSH数据源
预留的扩展点。本地版用 `FlutterPtyBackend`(自带 PTY本项目用自定义
`RttyPtyBackend` 把 WebSocket 原始 ANSI 流接入引擎,实现"远程 PTY + 本地
alacritty 渲染",服务端与 PC 端共享同一 Alacritty 引擎。
## 运行 ## 运行
```bash ```bash
@@ -32,6 +38,7 @@ flutter run -d windows
## 构建 ## 构建
```bash ```bash
# release 会连同 Alacritty Rust 引擎一起编译(首次较慢)
flutter build windows --release flutter build windows --release
# 产物build/windows/x64/runner/Release/rtty_desktop.exe # 产物build/windows/x64/runner/Release/rtty_desktop.exe
``` ```
@@ -42,14 +49,15 @@ flutter build windows --release
# Widget 测试(无需服务端) # Widget 测试(无需服务端)
flutter test test/widget_test.dart flutter test test/widget_test.dart
# 端到端集成测试(需先启动 Rust 服务端) # 端到端集成测试(需先启动 Rust 服务端;离线时自动跳过
flutter test test/e2e_ws_test.dart flutter test test/e2e_ws_test.dart
flutter test test/regression_ws_test.dart
``` ```
## 协议要点 ## 协议要点
- 连接 `ws://host:port/ws`(可选 `?session=<id>` 复用会话)。 - 连接 `ws://host:port/ws?client=desktop``client` 声明端类型,实现多端解耦)。
- 服务端 `ready` 帧携带会话 ID 与初始尺寸,驱动 xterm 网格。 - 服务端 `ready` 帧携带会话 ID 与初始尺寸,驱动引擎网格。
- PTY 输出以二进制帧下发,桌面端直接喂给 xterm 渲染。 - PTY 输出以二进制帧下发,`RttyPtyBackend.output` 喂给引擎渲染。
- 键盘输入与终端回写经 `{"type":"input","data":...}` 发回服务端写入 PTY。 - 键盘输入由引擎产生,经 `RttyPtyBackend.write` 发回服务端写入远端 PTY。
- 窗口尺寸变化时发送 `{"type":"resize","cols":..,"rows":..}` 同步网格 - 窗口尺寸变化`TerminalView.onPtyResize``RttyPtyBackend.resize` 同步。

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'rtty_client.dart'; import 'rtty_pty_backend.dart';
import 'theme.dart'; import 'theme.dart';
/// 顶部连接控制条。 /// 顶部连接控制条。

View File

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

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

View 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),
);
}
}

View File

@@ -1,14 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:xterm/xterm.dart'; import 'package:flutter_alacritty/flutter_alacritty.dart';
import 'connection_bar.dart'; import 'connection_bar.dart';
import 'rtty_client.dart'; import 'rtty_pty_backend.dart';
import 'terminal_config.dart';
import 'theme.dart'; import 'theme.dart';
/// 主终端页面。 /// 主终端页面。
/// ///
/// 组织连接控制条 + xterm 终端渲染区 + 底部状态栏。 /// 使用 `flutter_alacritty` 的 Rust 引擎TerminalEngine + TerminalView渲染
/// 断开时展示终端提示页,连接后进入实时渲染 /// 通过 [RttyPtyBackend] 桥接远程 Rtty Rust 服务端的 WebSocket 数据流
/// 断开时展示终端提示页,连接后进入实时工业级 ANSI 渲染。
class TerminalScreen extends StatefulWidget { class TerminalScreen extends StatefulWidget {
const TerminalScreen({super.key}); const TerminalScreen({super.key});
@@ -17,8 +19,12 @@ class TerminalScreen extends StatefulWidget {
} }
class _TerminalScreenState extends State<TerminalScreen> { class _TerminalScreenState extends State<TerminalScreen> {
late final Terminal _terminal; late final TerminalConfig _config;
late final RttyClient _client; TerminalEngine? _engine;
TerminalController _controller = TerminalController();
final FocusNode _focus = FocusNode();
RttyPtyBackend? _backend;
final _host = TextEditingController(text: '127.0.0.1'); final _host = TextEditingController(text: '127.0.0.1');
final _port = TextEditingController(text: '8080'); final _port = TextEditingController(text: '8080');
@@ -32,32 +38,17 @@ class _TerminalScreenState extends State<TerminalScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_terminal = Terminal(onOutput: (_) {}); _config = RttyTerminalConfig.build();
_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;
});
}
};
} }
@override @override
void dispose() { void dispose() {
_client.disconnect(); _tearDownSession();
_host.dispose(); _host.dispose();
_port.dispose(); _port.dispose();
_session.dispose(); _session.dispose();
_controller.dispose();
_focus.dispose();
super.dispose(); super.dispose();
} }
@@ -67,17 +58,65 @@ class _TerminalScreenState extends State<TerminalScreen> {
final session = _session.text.trim(); final session = _session.text.trim();
if (host.isEmpty) return; if (host.isEmpty) return;
_terminal.write('\x1b[2J\x1b[H'); _tearDownSession();
await _client.connect(host: host, port: port, session: session);
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sizeText = _state == ConnState.connected final sizeText = _state == ConnState.connected ? '$_cols × $_rows' : '';
? '$_cols × $_rows'
: '';
return Scaffold( return Scaffold(
backgroundColor: RttyTheme.background, backgroundColor: RttyTheme.background,
@@ -101,92 +140,36 @@ class _TerminalScreenState extends State<TerminalScreen> {
} }
Widget _buildBody() { Widget _buildBody() {
if (_state == ConnState.connected) { final engine = _engine;
return _TerminalViewport( if (_state == ConnState.connected && engine != null) {
terminal: _terminal, return Container(
cols: _cols, color: RttyTheme.background,
rows: _rows, padding: const EdgeInsets.all(16),
onResize: _handleResize, 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); 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), _Hint('Ctrl+Shift+C / V 复制粘贴', RttyTheme.textFaint),
Spacer(), Spacer(),
Text( Text(
'FLUTTER × XTERM TERMINAL', 'FLUTTER × ALACRITTY ENGINE',
style: TextStyle(color: RttyTheme.textFaint, fontSize: 10), style: TextStyle(color: RttyTheme.textFaint, fontSize: 10),
), ),
], ],

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:xterm/xterm.dart';
/// Rtty 桌面端主题。 /// 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,
);
} }

View File

@@ -1,6 +1,22 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: 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: async:
dependency: transitive dependency: transitive
description: description:
@@ -17,6 +33,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.1.2" 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: characters:
dependency: transitive dependency: transitive
description: description:
@@ -33,6 +57,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.1.2" 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: collection:
dependency: transitive dependency: transitive
description: description:
@@ -49,6 +81,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "3.1.2" 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: crypto:
dependency: transitive dependency: transitive
description: description:
@@ -65,14 +105,22 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.0.9" version: "1.0.9"
equatable: desktop_drop:
dependency: transitive dependency: transitive
description: description:
name: equatable name: desktop_drop
sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" sha256: aa1e797255bfbc76f9eb5aa4f61e5b68dbf69962ab1be6495816d2f251bc0d1f
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted 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: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -81,11 +129,35 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.3.3" 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: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -94,11 +166,96 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "6.0.0" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -131,6 +288,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "6.1.0" 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: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -155,6 +320,22 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.18.0" 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: path:
dependency: transitive dependency: transitive
description: description:
@@ -163,14 +344,102 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
quiver: path_provider:
dependency: transitive dependency: transitive
description: description:
name: quiver name: path_provider
sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -224,6 +493,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.7.11" 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: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -232,6 +509,78 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.4.0" 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: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -272,22 +621,22 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "3.0.3" version: "3.0.3"
xterm: xdg_directories:
dependency: "direct main"
description:
name: xterm
sha256: "168dfedca77cba33fdb6f52e2cd001e9fde216e398e89335c19b524bb22da3a2"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.0.0"
zmodem:
dependency: transitive dependency: transitive
description: description:
name: zmodem name: xdg_directories
sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf" sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted 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: sdks:
dart: ">=3.12.2 <4.0.0" dart: ">=3.12.2 <4.0.0"
flutter: ">=3.19.0" flutter: ">=3.44.0"

View File

@@ -35,7 +35,7 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
web_socket_channel: ^3.0.3 web_socket_channel: ^3.0.3
xterm: ^4.0.0 flutter_alacritty: ^2.4.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -1,49 +1,51 @@
// 端到端集成测试:桌面端连接层 ↔ Rust 服务端。 // 端到端集成测试:桌面端连接层RttyPtyBackend↔ Rust 服务端。
// //
// 运行前需先启动 Rust 服务端cd .. && cargo run // 运行前需先启动 Rust 服务端cd .. && cargo run
// 运行flutter test test/e2e_ws_test.dart // 运行flutter test test/e2e_ws_test.dart
import 'package:flutter_test/flutter_test.dart'; import 'dart:typed_data';
import 'package:xterm/xterm.dart';
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'; import 'support.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
test('RttyClient connects to Rust server and renders command output', test('RttyPtyBackend connects to Rust server and receives raw ANSI output',
() async { () async {
if (!await isServerUp('127.0.0.1', 8080)) { if (!await isServerUp('127.0.0.1', 8080)) {
markTestSkipped('Rtty server not running'); markTestSkipped('Rtty server not running');
return; return;
} }
final terminal = Terminal(); final backend = RttyPtyBackend();
final client = RttyClient(terminal);
final states = <ConnState>[]; final states = <ConnState>[];
String? sessionId; String? sessionId;
final raw = StringBuffer(); final raw = StringBuffer();
client.onStateChanged = (s) => states.add(s); backend.onStateChanged = (s) => states.add(s);
client.onReady = (id, cols, rows) => sessionId = '$id:$cols:$rows'; backend.onReady = (id, cols, rows) => sessionId = '$id:$cols:$rows';
client.onRawData = (d) => raw.write(d); 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 帧)。 // 等待连接就绪socket 已连接 + 收到 ready 帧)。
final deadline = DateTime.now().add(const Duration(seconds: 5)); 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)) { DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
} }
expect(client.isConnected, isTrue, expect(backend.state, ConnState.connected,
reason: '未能连接服务端 states=$states'); reason: '未能连接服务端 states=$states');
expect(sessionId, isNotNull, expect(sessionId, isNotNull, reason: '未收到 ready 帧 states=$states');
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; String? matchedLine;
final outDeadline = DateTime.now().add(const Duration(seconds: 8)); final outDeadline = DateTime.now().add(const Duration(seconds: 8));
@@ -57,9 +59,9 @@ void main() {
expect(matchedLine, isNotNull, expect(matchedLine, isNotNull,
reason: '未捕获到命令回显。流末尾:' 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(); await backend.close();
expect(client.state, ConnState.disconnected); expect(backend.state, ConnState.disconnected);
}); });
} }

View File

@@ -6,6 +6,12 @@
#include "generated_plugin_registrant.h" #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) { void RegisterPlugins(flutter::PluginRegistry* registry) {
DesktopDropPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DesktopDropPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
} }

View File

@@ -3,9 +3,14 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
desktop_drop
url_launcher_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_pty_new
jni
rust_lib_flutter_alacritty
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)