diff --git a/desktop/README.md b/desktop/README.md index 8bf74d8..d869ad8 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -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=` 复用会话)。 -- 服务端 `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` 同步。 diff --git a/desktop/lib/src/connection_bar.dart b/desktop/lib/src/connection_bar.dart index c75e961..e25971d 100644 --- a/desktop/lib/src/connection_bar.dart +++ b/desktop/lib/src/connection_bar.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'rtty_client.dart'; +import 'rtty_pty_backend.dart'; import 'theme.dart'; /// 顶部连接控制条。 diff --git a/desktop/lib/src/rtty_client.dart b/desktop/lib/src/rtty_client.dart deleted file mode 100644 index 25a6a63..0000000 --- a/desktop/lib/src/rtty_client.dart +++ /dev/null @@ -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 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 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) { - // 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) 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); - } -} diff --git a/desktop/lib/src/rtty_pty_backend.dart b/desktop/lib/src/rtty_pty_backend.dart new file mode 100644 index 0000000..2f91739 --- /dev/null +++ b/desktop/lib/src/rtty_pty_backend.dart @@ -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 _outputCtl = + StreamController.broadcast(); + final Completer _exitCodeCtl = Completer(); + + 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 get output => _outputCtl.stream; + + @override + Future get exitCode => _exitCodeCtl.future; + + @override + ValueListenable? get isForegroundProcessRunning => null; + + /// 连接到 Rtty 服务端。 + Future 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) { + // 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) 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 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(); + } +} diff --git a/desktop/lib/src/terminal_config.dart b/desktop/lib/src/terminal_config.dart new file mode 100644 index 0000000..c649740 --- /dev/null +++ b/desktop/lib/src/terminal_config.dart @@ -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), + ); + } +} diff --git a/desktop/lib/src/terminal_screen.dart b/desktop/lib/src/terminal_screen.dart index 0c9ee7a..f8f92eb 100644 --- a/desktop/lib/src/terminal_screen.dart +++ b/desktop/lib/src/terminal_screen.dart @@ -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 { - 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 { @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 { 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 _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 { } 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), ), ], diff --git a/desktop/lib/src/theme.dart b/desktop/lib/src/theme.dart index 2bd7669..500cbe3 100644 --- a/desktop/lib/src/theme.dart +++ b/desktop/lib/src/theme.dart @@ -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, - ); } diff --git a/desktop/pubspec.lock b/desktop/pubspec.lock index 5721623..52aa674 100644 --- a/desktop/pubspec.lock +++ b/desktop/pubspec.lock @@ -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" diff --git a/desktop/pubspec.yaml b/desktop/pubspec.yaml index bd39845..12d0075 100644 --- a/desktop/pubspec.yaml +++ b/desktop/pubspec.yaml @@ -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: diff --git a/desktop/test/e2e_ws_test.dart b/desktop/test/e2e_ws_test.dart index 53dc01b..d98309f 100644 --- a/desktop/test/e2e_ws_test.dart +++ b/desktop/test/e2e_ws_test.dart @@ -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 = []; 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.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); }); } diff --git a/desktop/windows/flutter/generated_plugin_registrant.cc b/desktop/windows/flutter/generated_plugin_registrant.cc index 8b6d468..a3237bf 100644 --- a/desktop/windows/flutter/generated_plugin_registrant.cc +++ b/desktop/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,12 @@ #include "generated_plugin_registrant.h" +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + DesktopDropPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("DesktopDropPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/desktop/windows/flutter/generated_plugins.cmake b/desktop/windows/flutter/generated_plugins.cmake index b93c4c3..4a944d1 100644 --- a/desktop/windows/flutter/generated_plugins.cmake +++ b/desktop/windows/flutter/generated_plugins.cmake @@ -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)