Compare commits

..

9 Commits

46 changed files with 4422 additions and 25 deletions

View File

@@ -22,10 +22,11 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1.0"
dashmap = "6.0" # 高并发线程安全的 SessionMap
futures-util = "0.3"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true
strip = true

191
PROJECT_SUMMARY.md Normal file
View File

@@ -0,0 +1,191 @@
# 🚀 Rtty 项目实现总结与目录导航
> **Rtty** 是一款基于 Rust + Flutter 构建的高性能、跨平台分布式虚拟终端系统。
> 服务端Rust + Alacritty 引擎)在内存中维持"真相源",通过 WebSocket 与各端解耦;
> 桌面端Flutter + `flutter_alacritty`)共享同一 Alacritty 引擎做工业级 ANSI 渲染;
> 移动端Flutter消费语义化快照做卡片化重排。
---
## 一、总体架构
```mermaid
flowchart LR
subgraph Server["rtty-server (Rust)"]
PTY["portable-pty<br/>伪终端 / Shell"]
ENGINE["alacritty_terminal<br/>真相源 · ANSI 解析 · 屏幕网格"]
WS["Axum WebSocket<br/>session 管理 / 广播"]
end
subgraph Desktop["rtty-desktop (Flutter Windows)"]
BACKEND["RttyPtyBackend<br/>WebSocket 桥接"]
DENGINE["flutter_alacritty<br/>Alacritty 引擎渲染"]
end
subgraph Mobile["rtty-mobile (Flutter)"]
MCLIENT["RttyMobileClient<br/>语义快照消费"]
MUI["卡片化 / 换行 / Keybar"]
end
PTY --> ENGINE --> WS
WS -->|"原始 ANSI 二进制帧<br/>client=desktop"| BACKEND --> DENGINE
WS -->|"语义化 JSON 快照<br/>client=mobile"| MCLIENT --> MUI
```
---
## 二、目录树状图与功能对应
### 1. 服务端内核 `src/`Rust
```
src/
├── main.rs # 程序入口:全局 AppState会话表、Axum 路由、配置加载
├── config.rs # ServerConfighost/port/shell/尺寸/scrollback/并发限制/空闲超时(环境变量 RTTY_* 可覆盖)
├── terminal/ # 终端核心PTY + Alacritty 引擎)
│ ├── mod.rs # terminal 模块声明
│ ├── pty.rs # PtySessionportable-pty 伪终端封装(拉起 Shell、读写、resize、kill
│ └── engine.rs # TerminalEnginealacritty_terminal 封装ANSI 解析、网格维护、语义快照生成、PtyWrite 回写)
└── ws/ # WebSocket 通信层
├── mod.rs # ws 模块声明
├── protocol.rs # 协议ClientMessageinput/resize/claim_control/ping、ServerMessageready/snapshot/control/error/session_closed、MobileSnapshot
└── handler.rs # 会话管理多端解耦广播、并发限制、控制权强制、会话清理EOF + 进程监控 + 空闲超时)
```
| 功能 | 对应文件 |
|------|---------|
| 伪终端创建 / Shell 拉起 / 读写 / resize | `src/terminal/pty.rs` |
| ANSI 解析 / 屏幕网格 / 滚动历史 / 语义快照 | `src/terminal/engine.rs` |
| 断线重连状态恢复(初始快照) | `src/ws/handler.rs` |
| 多端解耦PC 收 ANSI、移动收快照 | `src/ws/handler.rs` |
| 控制权强制(非控制者输入被拒) | `src/ws/handler.rs` |
| 会话清理(泄漏防护) | `src/ws/handler.rs` |
| 配置加载(环境变量) | `src/config.rs` |
---
### 2. 桌面端 `desktop/`Flutter Windows
```
desktop/
├── lib/
│ ├── main.dart # 入口:初始化 RustLibflutter_rust_bridge后启动应用
│ └── src/
│ ├── rtty_pty_backend.dart # RttyPtyBackend实现 flutter_alacritty 的 PtyBackend 接口,桥接 WebSocket
│ ├── terminal_config.dart # RttyTerminalConfig把 Rtty 配色/字体映射为 Alacritty TerminalConfig
│ ├── terminal_screen.dart # 主页面TerminalEngine + TerminalView、连接控制、resize 防抖、双向接线
│ ├── connection_bar.dart # 顶部连接控制条状态灯、host/port/session 输入、会话徽章、连接按钮
│ └── theme.dart # 工业终端主题(深炭黑 + 磷光青绿)
├── test/ # 单元测试(服务端离线时集成测试自动跳过)
│ ├── e2e_ws_test.dart # RttyPtyBackend 连真实服务端接收原始 ANSI 输出
│ ├── regression_ws_test.dart# 多端解耦 + 控制权强制 + 会话清理回归
│ ├── support.dart # 测试共享工具(服务端探测)
│ └── widget_test.dart # Widget 渲染测试
├── integration_test/ # 真实 Windows 桌面集成测试
│ ├── e2e_test.dart # 连接 + 渲染 + 命令回显闭环
│ └── cd_resize_test.dart # cd 跨目录 + resize 内容完整性
├── windows/ # Windows 平台 CMake/runnerflutter 生成)
├── pubspec.yaml # 依赖web_socket_channel、flutter_alacritty
└── README.md # 桌面端说明
```
| 功能 | 对应文件 |
|------|---------|
| Alacritty Rust 引擎初始化 | `lib/main.dart``await RustLib.init()` |
| WebSocket ↔ PtyBackend 桥接(远程数据源) | `lib/src/rtty_pty_backend.dart` |
| 引擎接线(输出喂渲染、输入回传服务端) | `lib/src/terminal_screen.dart` |
| 100% 工业级 ANSI 渲染vim/htop/tmux | `lib/src/terminal_screen.dart` + `flutter_alacritty` |
| resize 防抖TUI resize 稳定性) | `lib/src/terminal_screen.dart` |
| 连接控制条 / 会话管理 | `lib/src/connection_bar.dart` |
| 主题 / 配色 | `lib/src/theme.dart``lib/src/terminal_config.dart` |
---
### 3. 移动端 `mobile/`Flutter
```
mobile/
├── lib/
│ ├── main.dart # 入口
│ └── src/
│ ├── rtty_mobile_client.dart # RttyMobileClient消费 mobile_snapshot 语义快照,发送 input/resize/claim_control
│ └── theme.dart # 移动端主题(卡片化美学)
├── android/ # Android 平台工程flutter 生成)
├── ios/ # iOS 平台工程flutter 生成)
└── pubspec.yaml # 依赖web_socket_channel
```
> ⚠️ **当前状态**移动端为脚手架骨架客户端连接层与主题已就绪卡片化重排、Keybar 等核心 UI 尚未实现,尚未提交 Git。这是规划中的下一个里程碑。
| 功能 | 对应文件 |
|------|---------|
| 语义快照 WebSocket 消费 | `mobile/lib/src/rtty_mobile_client.dart` |
| 智能换行 / 卡片化 / Keybar | ⏳ 待实现 |
---
### 4. 根目录其他文件
```
Rtty/
├── src/ # 服务端源码(见上)
├── desktop/ # 桌面端(见上)
├── mobile/ # 移动端(见上)
├── Cargo.toml # 服务端依赖与 release 优化配置
├── Cargo.lock # Rust 依赖锁
├── README.md # 项目设计总结与架构指南
├── LICENSE # 许可证
└── .gitignore # 忽略 target/.idea/Cargo.lock 等
```
---
## 三、核心功能清单(实现 vs 规划)
| 功能 | 服务端 | 桌面端 | 移动端 | 状态 |
|------|:---:|:---:|:---:|:---:|
| PTY / Shell 会话 | ✅ | — | — | 已实现 |
| Alacritty 引擎ANSI 解析 / 网格) | ✅ | ✅ | — | 已实现 |
| 多端状态保持(断线重连恢复) | ✅ | ✅ | ⏳ | 已实现 |
| 多端解耦PC ANSI / 移动快照) | ✅ | ✅ | ⏳ | 已实现 |
| 控制权强制 | ✅ | — | ⏳ | 已实现 |
| 会话清理(空闲超时) | ✅ | — | — | 已实现 |
| 100% 工业级 ANSI 渲染 | — | ✅ | — | 已实现 |
| 命令执行 / cd / TUI resize 稳定 | — | ✅ | — | 已实现 |
| 智能文本换行Word Wrap | — | — | ⏳ | 规划 |
| 卡片化命令交互 | — | — | ⏳ | 规划 |
| 移动端 Keybar | — | — | ⏳ | 规划 |
| 分布式横向扩展 | ⏳ | — | — | 规划 |
| 服务端鉴权 | ⏳ | — | — | 规划 |
---
## 四、运行与构建
```bash
# 1. 服务端(仓库根目录)
cargo run # 监听 ws://0.0.0.0:8080
# 2. 桌面端
cd desktop
flutter run -d windows # 运行
flutter build windows --release # 构建(含 Rust 引擎编译)
# 3. 测试
cargo test # 服务端
flutter test # 桌面端单元测试(服务端离线时集成测试跳过)
flutter test integration_test -d windows # 桌面端真实桌面集成测试
```
---
## 五、目录导航速查
- **要改终端解析/网格逻辑** → `src/terminal/engine.rs`
- **要改 PTY/进程管理** → `src/terminal/pty.rs`
- **要改协议/消息格式** → `src/ws/protocol.rs`
- **要改会话/多端/清理逻辑** → `src/ws/handler.rs`
- **要改桌面端连接桥接** → `desktop/lib/src/rtty_pty_backend.dart`
- **要改桌面端渲染/接线** → `desktop/lib/src/terminal_screen.dart`
- **要改桌面端 UI/主题** → `desktop/lib/src/connection_bar.dart``theme.dart`
- **要改移动端连接层** → `mobile/lib/src/rtty_mobile_client.dart`

45
desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

30
desktop/.metadata Normal file
View File

@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "ad70ec4617166f1c38e5d2bfd388af71fda14f06"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: windows
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

63
desktop/README.md Normal file
View File

@@ -0,0 +1,63 @@
# Rtty Desktop (桌面端)
Rtty 的 Flutter Windows 桌面终端客户端。连接 `rtty-server`Rust 后端),通过
**`flutter_alacritty`**Alacritty Rust 引擎的 Flutter 绑定)做 **100% 工业级
ANSI 渲染**,完美支持 vim / neovim / htop / tmux 等全屏 TUI 应用。
## 架构
```
rtty-server (Rust, alacritty_terminal 真相源)
│ WebSocket (ws://host:8080/ws?client=desktop)
│ • JSON 控制帧 (ready / error / session_closed)
│ • 二进制帧 = 原始 ANSI 字节流
rtty-desktop (Flutter / Windows)
• 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
# 1. 先启动 Rust 服务端(仓库根目录)
cd .. && cargo run
# 2. 启动桌面端
cd desktop
flutter run -d windows
```
## 构建
```bash
# release 会连同 Alacritty Rust 引擎一起编译(首次较慢)
flutter build windows --release
# 产物build/windows/x64/runner/Release/rtty_desktop.exe
```
## 测试
```bash
# Widget 测试(无需服务端)
flutter test test/widget_test.dart
# 端到端集成测试(需先启动 Rust 服务端;离线时自动跳过)
flutter test test/e2e_ws_test.dart
flutter test test/regression_ws_test.dart
```
## 协议要点
- 连接 `ws://host:port/ws?client=desktop``client` 声明端类型,实现多端解耦)。
- 服务端 `ready` 帧携带会话 ID 与初始尺寸,驱动引擎网格。
- PTY 输出以二进制帧下发,经 `RttyPtyBackend.output` 喂给引擎渲染。
- 键盘输入由引擎产生,经 `RttyPtyBackend.write` 发回服务端写入远端 PTY。
- 窗口尺寸变化经 `TerminalView.onPtyResize``RttyPtyBackend.resize` 同步。

View File

@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View File

@@ -0,0 +1,88 @@
// 专项验证cd 跨目录 + resize 后内容不重复。
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_alacritty/flutter_alacritty.dart';
import 'package:integration_test/integration_test.dart';
import 'package:rtty_desktop/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('cd 跨目录 + resize 内容完整性', (WidgetTester tester) async {
await app.main();
await tester.pumpAndSettle();
await tester.tap(find.text('CONNECT'));
await tester.pump();
// 等待连接。
final deadline = DateTime.now().add(const Duration(seconds: 8));
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 200));
if (find.text('AWAITING CONNECTION').evaluate().isEmpty) break;
}
expect(find.text('AWAITING CONNECTION'), findsNothing);
final engine = tester
.state<TerminalViewState>(find.byType(TerminalView))
.widget
.engine;
engine.initializeEmpty(24, 80);
void send(String s) =>
engine.write(Uint8List.fromList(s.codeUnits));
String gridText() {
final g = engine.gridForView;
final sb = StringBuffer();
for (var r = 0; r < g.rows; r++) {
for (var c = 0; c < g.columns; c++) {
final cp = g.codepointAt(r, c);
sb.writeCharCode(cp == 0 ? 32 : cp);
}
sb.write('\n');
}
return sb.toString();
}
Future<bool> waitFor(bool Function(String) pred,
{int secs = 6}) async {
final end = DateTime.now().add(Duration(seconds: secs));
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
if (pred(gridText())) return true;
}
return pred(gridText());
}
// ---- 1. cd 跨目录 ----
final target = r'C:\Users\NianJiu\rttytest\subdir';
send('cd $target\r\n');
final cdOk = await waitFor((t) => t.contains('rttytest\\subdir'));
debugPrint('cd 后 prompt 含目标目录: $cdOk');
if (!cdOk) debugPrint('--- grid ---\n${gridText()}');
expect(cdOk, isTrue, reason: 'cd 到绝对路径后 prompt 应显示新目录');
// ---- 2. resize 后内容不重复 ----
// 记录 resize 前的非空行。
send('dir\r\n');
final beforeOk = await waitFor((t) => t.contains('subdir') && t.contains('dir'));
debugPrint('dir 输出: $beforeOk');
// 连续 resize 数次(模拟窗口拖动),验证内容不产生重复/错乱。
for (final (c, r) in [(100, 30), (120, 35), (90, 28), (110, 32)]) {
engine.resize(columns: c, rows: r);
await tester.pump(const Duration(milliseconds: 150));
}
engine.resize(columns: 100, rows: 30);
await tester.pump(const Duration(milliseconds: 300));
// 重置引擎网格尺寸后,发送简单命令,验证仍能正常回显(无重复错乱)。
send('echo RESIZE_OK\r\n');
final ok = await waitFor((t) => t.contains('RESIZE_OK'));
debugPrint('resize 后命令回显: $ok');
expect(ok, isTrue, reason: 'resize 后命令应正常回显且无重复');
debugPrint('--- 最终 grid ---\n${gridText()}');
});
}

View File

@@ -0,0 +1,80 @@
// 端到端集成测试:在真实 Windows 桌面运行完整链路。
//
// 验证RustLib.init() → 点 CONNECT → TerminalEngineAlacritty Rust 引擎)创建
// → 连上服务端 → 命令回显(内容渲染 + 输入通路)。
//
// 运行前需先启动 Rust 服务端cd .. && cargo run
// 运行flutter test integration_test -d windows
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_alacritty/flutter_alacritty.dart';
import 'package:integration_test/integration_test.dart';
import 'package:rtty_desktop/main.dart' as app;
import 'package:rtty_desktop/src/terminal_screen.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('桌面端连接服务端并渲染命令输出', (WidgetTester tester) async {
await app.main();
await tester.pumpAndSettle();
// 未连接提示。
expect(find.text('AWAITING CONNECTION'), findsOneWidget);
// 点击 CONNECT。
await tester.tap(find.text('CONNECT'));
await tester.pump();
// 等待连接就绪(进入 connected 状态,提示页消失)。
final deadline = DateTime.now().add(const Duration(seconds: 8));
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 200));
if (find.text('AWAITING CONNECTION').evaluate().isEmpty) break;
}
expect(find.text('AWAITING CONNECTION'), findsNothing,
reason: '连接后应进入终端渲染');
// Alacritty TerminalView 应渲染。
expect(find.byType(TerminalView), findsOneWidget,
reason: 'Alacritty TerminalView 应渲染');
// 通过引擎输出通路发送一条命令(模拟用户输入),验证:
// 服务端回显 → backend.output → engine.feed → 网格渲染。
final engine = tester.state<TerminalViewState>(find.byType(TerminalView)).widget.engine;
final token = 'RTTY_INT_${DateTime.now().millisecondsSinceEpoch}';
engine.write(Uint8List.fromList('echo $token\r\n'.codeUnits));
// 轮询引擎网格,等待命令回显进入渲染。
String? found;
final outDeadline = DateTime.now().add(const Duration(seconds: 8));
while (DateTime.now().isBefore(outDeadline)) {
await tester.pump(const Duration(milliseconds: 200));
final gridText = _gridText(engine);
if (gridText.contains(token)) {
found = gridText;
break;
}
}
expect(found, isNotNull, reason: '命令回显应进入引擎网格');
debugPrint('回显捕获: ${found!.trim().split('\n').last}');
// 输入通路也验证TerminalView 聚焦时按键应经 engine.write → backend → 服务端。
expect(find.byType(TerminalScreen), findsOneWidget);
});
}
/// 从引擎镜像网格提取可视文本。
String _gridText(TerminalEngine engine) {
final grid = engine.gridForView;
final sb = StringBuffer();
for (var r = 0; r < grid.rows; r++) {
for (var c = 0; c < grid.columns; c++) {
final cp = grid.codepointAt(r, c);
sb.writeCharCode(cp == 0 ? 32 : cp);
}
sb.write('\n');
}
return sb.toString();
}

33
desktop/lib/main.dart Normal file
View File

@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_alacritty/flutter_alacritty.dart';
import 'src/terminal_screen.dart';
import 'src/theme.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// 初始化 flutter_alacritty 的 Alacritty Rust 引擎FFI 绑定)。
// cargokit 已将 Rust cdylib 打包进应用,无需外部库路径。
await RustLib.init();
// 锁定竖排字符方向(终端场景无需旋转)。
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
runApp(const RttyApp());
}
class RttyApp extends StatelessWidget {
const RttyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Rtty Desktop',
debugShowCheckedModeBanner: false,
theme: RttyTheme.app(),
home: const TerminalScreen(),
);
}
}

View File

@@ -0,0 +1,319 @@
import 'package:flutter/material.dart';
import 'rtty_pty_backend.dart';
import 'theme.dart';
/// 顶部连接控制条。
///
/// 精炼的工业风控制条:状态指示灯 + 服务器地址输入 + 会话 ID + 连接按钮。
/// 断开状态下可编辑,连接成功后锁定并显示会话信息。
class ConnectionBar extends StatelessWidget {
const ConnectionBar({
super.key,
required this.state,
required this.hostController,
required this.portController,
required this.sessionController,
required this.sessionId,
required this.terminalSize,
required this.onConnect,
required this.onDisconnect,
});
final ConnState state;
final TextEditingController hostController;
final TextEditingController portController;
final TextEditingController sessionController;
final String? sessionId;
final String terminalSize;
final VoidCallback onConnect;
final VoidCallback onDisconnect;
bool get _connected =>
state == ConnState.connected || state == ConnState.connecting;
@override
Widget build(BuildContext context) {
return Container(
height: 56,
decoration: BoxDecoration(
color: RttyTheme.surface,
border: Border(bottom: BorderSide(color: RttyTheme.border)),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
_StatusLight(state: state),
const SizedBox(width: 12),
_ConnectionLabel(state: state),
const SizedBox(width: 16),
_Divider(),
const SizedBox(width: 16),
_EditableField(
label: 'HOST',
controller: hostController,
enabled: !_connected,
width: 150,
),
const SizedBox(width: 8),
_EditableField(
label: 'PORT',
controller: portController,
enabled: !_connected,
width: 64,
numeric: true,
),
const SizedBox(width: 8),
if (!_connected) ...[
_EditableField(
label: 'SESSION',
controller: sessionController,
enabled: true,
width: 120,
hint: 'auto',
),
] else ...[
_SessionBadge(sessionId: sessionId),
],
const Spacer(),
_TerminalSizeBadge(text: terminalSize),
const SizedBox(width: 12),
_ConnectButton(
state: state,
onConnect: onConnect,
onDisconnect: onDisconnect,
),
],
),
);
}
}
class _StatusLight extends StatelessWidget {
const _StatusLight({required this.state});
final ConnState state;
Color get _color => switch (state) {
ConnState.disconnected => RttyTheme.textFaint,
ConnState.connecting => RttyTheme.accent,
ConnState.connected => RttyTheme.primary,
ConnState.error => RttyTheme.danger,
};
@override
Widget build(BuildContext context) {
return Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: _color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: _color.withValues(alpha: 0.55),
blurRadius: 6,
spreadRadius: 1,
),
],
),
);
}
}
class _ConnectionLabel extends StatelessWidget {
const _ConnectionLabel({required this.state});
final ConnState state;
@override
Widget build(BuildContext context) {
final (label, color) = switch (state) {
ConnState.disconnected => ('DISCONNECTED', RttyTheme.textFaint),
ConnState.connecting => ('CONNECTING', RttyTheme.accent),
ConnState.connected => ('CONNECTED', RttyTheme.primary),
ConnState.error => ('CONNECTION ERROR', RttyTheme.danger),
};
return Text(
label,
style: TextStyle(
color: color,
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.4,
),
);
}
}
class _Divider extends StatelessWidget {
const _Divider();
@override
Widget build(BuildContext context) {
return Container(width: 1, height: 24, color: RttyTheme.border);
}
}
class _EditableField extends StatelessWidget {
const _EditableField({
required this.label,
required this.controller,
required this.enabled,
required this.width,
this.numeric = false,
this.hint,
});
final String label;
final TextEditingController controller;
final bool enabled;
final double width;
final bool numeric;
final String? hint;
@override
Widget build(BuildContext context) {
return SizedBox(
width: width,
child: TextField(
controller: controller,
enabled: enabled,
keyboardType:
numeric ? TextInputType.number : TextInputType.text,
style: const TextStyle(
color: RttyTheme.text,
fontSize: 12,
fontFamily: 'Consolas',
decorationThickness: 0,
),
cursorColor: RttyTheme.primary,
decoration: InputDecoration(
labelText: label,
labelStyle: const TextStyle(
color: RttyTheme.textFaint,
fontSize: 10,
letterSpacing: 1.2,
fontWeight: FontWeight.w600,
),
hintText: hint,
hintStyle: const TextStyle(color: RttyTheme.textFaint, fontSize: 12),
isDense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: RttyTheme.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: RttyTheme.primaryDim),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(
color: RttyTheme.border.withValues(alpha: 0.5),
),
),
),
),
);
}
}
class _SessionBadge extends StatelessWidget {
const _SessionBadge({required this.sessionId});
final String? sessionId;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: RttyTheme.surfaceHi,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: RttyTheme.border),
),
child: Text(
'SID ${sessionId ?? ''}',
style: const TextStyle(
color: RttyTheme.textDim,
fontSize: 11,
fontFamily: 'Consolas',
letterSpacing: 0.4,
),
),
);
}
}
class _TerminalSizeBadge extends StatelessWidget {
const _TerminalSizeBadge({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
style: const TextStyle(
color: RttyTheme.textFaint,
fontSize: 11,
fontFamily: 'Consolas',
),
);
}
}
class _ConnectButton extends StatelessWidget {
const _ConnectButton({
required this.state,
required this.onConnect,
required this.onDisconnect,
});
final ConnState state;
final VoidCallback onConnect;
final VoidCallback onDisconnect;
@override
Widget build(BuildContext context) {
final connected = state == ConnState.connected;
final connecting = state == ConnState.connecting;
final bg = connected
? RttyTheme.primaryDim.withValues(alpha: 0.25)
: RttyTheme.primary.withValues(alpha: 0.14);
final fg = connected ? RttyTheme.danger : RttyTheme.primary;
final label = connected
? 'DISCONNECT'
: connecting
? 'CONNECTING…'
: 'CONNECT';
return SizedBox(
height: 34,
child: FilledButton(
onPressed: connected ? onDisconnect : onConnect,
style: FilledButton.styleFrom(
backgroundColor: bg,
foregroundColor: fg,
padding: const EdgeInsets.symmetric(horizontal: 18),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
side: BorderSide(color: fg.withValues(alpha: 0.5)),
),
),
child: Text(
label,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
),
),
),
);
}
}

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

@@ -0,0 +1,381 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_alacritty/flutter_alacritty.dart';
import 'connection_bar.dart';
import 'rtty_pty_backend.dart';
import 'terminal_config.dart';
import 'theme.dart';
/// 主终端页面。
///
/// 使用 `flutter_alacritty` 的 Rust 引擎TerminalEngine + TerminalView渲染
/// 通过 [RttyPtyBackend] 桥接远程 Rtty Rust 服务端的 WebSocket 数据流。
/// 断开时展示终端提示页,连接后进入实时工业级 ANSI 渲染。
class TerminalScreen extends StatefulWidget {
const TerminalScreen({super.key});
@override
State<TerminalScreen> createState() => _TerminalScreenState();
}
class _TerminalScreenState extends State<TerminalScreen> {
late final TerminalConfig _config;
TerminalEngine? _engine;
TerminalController _controller = TerminalController();
final FocusNode _focus = FocusNode();
RttyPtyBackend? _backend;
StreamSubscription<Uint8List>? _engineOutputSub;
StreamSubscription<Uint8List>? _backendOutputSub;
/// resize 防抖:窗口拖动时 onPtyResize 会高频触发,合并为最后一次。
Timer? _resizeDebounce;
int _pendingCols = 0;
int _pendingRows = 0;
final _host = TextEditingController(text: '127.0.0.1');
final _port = TextEditingController(text: '8080');
final _session = TextEditingController();
ConnState _state = ConnState.disconnected;
String? _sessionId;
int _cols = 0;
int _rows = 0;
@override
void initState() {
super.initState();
_config = RttyTerminalConfig.build();
}
@override
void dispose() {
_tearDownSession();
_host.dispose();
_port.dispose();
_session.dispose();
_controller.dispose();
_focus.dispose();
super.dispose();
}
Future<void> _connect() async {
final host = _host.text.trim();
final port = int.tryParse(_port.text.trim()) ?? 8080;
final session = _session.text.trim();
if (host.isEmpty) return;
_tearDownSession();
setState(() {
_engine = TerminalEngine(config: _config);
_controller = TerminalController()..attach(_engine!);
_backend = RttyPtyBackend();
});
final engine = _engine!;
final backend = _backend!;
// 双向接线backend 输出(服务端 ANSI→ 引擎渲染;
// 引擎输出(用户输入)→ backend 发回服务端写入远端 PTY。
_engineOutputSub = engine.output.listen(backend.write);
_backendOutputSub = backend.output.listen(engine.feed);
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);
}
void _disconnect() => _tearDownSession();
void _tearDownSession() {
_resizeDebounce?.cancel();
_resizeDebounce = null;
_engineOutputSub?.cancel();
_engineOutputSub = null;
_backendOutputSub?.cancel();
_backendOutputSub = null;
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) {
if (!mounted) return;
setState(() {
_cols = cols;
_rows = rows;
});
// 合并连续 resize只发送最后一次避免 TUI 频繁 SIGWINCH 重绘错乱。
_pendingCols = cols;
_pendingRows = rows;
_resizeDebounce?.cancel();
_resizeDebounce = Timer(const Duration(milliseconds: 80), () {
final b = _backend;
if (b == null) return;
// PtyBackend.resize(rows, columns) — 注意参数顺序为行/列。
b.resize(_pendingRows, _pendingCols);
});
}
@override
Widget build(BuildContext context) {
final sizeText = _state == ConnState.connected ? '$_cols × $_rows' : '';
return Scaffold(
backgroundColor: RttyTheme.background,
body: Column(
children: [
ConnectionBar(
state: _state,
hostController: _host,
portController: _port,
sessionController: _session,
sessionId: _sessionId,
terminalSize: sizeText,
onConnect: _connect,
onDisconnect: _disconnect,
),
Expanded(child: _buildBody()),
const _StatusBar(),
],
),
);
}
Widget _buildBody() {
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);
}
}
/// 未连接时的提示面板。
class _IdlePane extends StatelessWidget {
const _IdlePane({required this.state});
final ConnState state;
@override
Widget build(BuildContext context) {
final (title, subtitle) = switch (state) {
ConnState.disconnected => (
'AWAITING CONNECTION',
'输入服务端地址后点击 CONNECT 建立终端会话',
),
ConnState.connecting => (
'CONNECTING',
'正在协商 WebSocket 终端会话…',
),
ConnState.error => (
'CONNECTION LOST',
'无法建立连接,请检查服务端是否已启动',
),
ConnState.connected => ('', ''),
};
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_ScanlineGlyph(state: state),
const SizedBox(height: 28),
Text(
title,
style: const TextStyle(
color: RttyTheme.textDim,
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: 3,
),
),
const SizedBox(height: 10),
Text(
subtitle,
style: const TextStyle(color: RttyTheme.textFaint, fontSize: 12),
),
],
),
);
}
}
/// 终端提示区的装饰性扫描线字形。
class _ScanlineGlyph extends StatelessWidget {
const _ScanlineGlyph({required this.state});
final ConnState state;
@override
Widget build(BuildContext context) {
final color = switch (state) {
ConnState.connected => RttyTheme.primary,
ConnState.connecting => RttyTheme.accent,
ConnState.error => RttyTheme.danger,
_ => RttyTheme.textFaint,
};
return Container(
width: 96,
height: 96,
decoration: BoxDecoration(
border: Border.all(color: color.withValues(alpha: 0.5), width: 1.5),
borderRadius: BorderRadius.circular(12),
),
child: CustomPaint(painter: _ScanlinePainter(color)),
);
}
}
class _ScanlinePainter extends CustomPainter {
_ScanlinePainter(this.color);
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color.withValues(alpha: 0.18)
..strokeWidth = 1;
const spacing = 6.0;
for (double y = 0; y < size.height; y += spacing) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
final cursorPaint = Paint()
..color = color.withValues(alpha: 0.8)
..strokeWidth = 2;
canvas.drawRect(
Rect.fromLTWH(size.width * 0.3, size.height * 0.45, 24, 14),
cursorPaint..style = PaintingStyle.stroke,
);
}
@override
bool shouldRepaint(covariant _ScanlinePainter old) => old.color != color;
}
/// 底部状态栏:会话 ID、连接状态、按键提示。
class _StatusBar extends StatelessWidget {
const _StatusBar();
@override
Widget build(BuildContext context) {
return Container(
height: 26,
decoration: BoxDecoration(
color: RttyTheme.surface,
border: Border(top: BorderSide(color: RttyTheme.border)),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: const [
_StatusDot(),
SizedBox(width: 8),
Text(
'RTTY DESKTOP',
style: TextStyle(
color: RttyTheme.textFaint,
fontSize: 10,
letterSpacing: 1.6,
fontWeight: FontWeight.w600,
),
),
SizedBox(width: 20),
_Hint('拖选复制', RttyTheme.textFaint),
SizedBox(width: 14),
_Hint('Ctrl+Shift+C / V 复制粘贴', RttyTheme.textFaint),
Spacer(),
Text(
'FLUTTER × ALACRITTY ENGINE',
style: TextStyle(color: RttyTheme.textFaint, fontSize: 10),
),
],
),
);
}
}
class _StatusDot extends StatelessWidget {
const _StatusDot();
@override
Widget build(BuildContext context) {
return Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: RttyTheme.primary,
shape: BoxShape.circle,
),
);
}
}
class _Hint extends StatelessWidget {
const _Hint(this.text, this.color);
final String text;
final Color color;
@override
Widget build(BuildContext context) {
return Text(
text,
style: TextStyle(color: color, fontSize: 10, fontFamily: 'Consolas'),
);
}
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
/// Rtty 桌面端主题。
///
/// 设计方向:工业终端美学。深炭黑底 + 磷光青绿功能色,
/// 借鉴老式 CRT 终端的磷光质感,克制而精确。
class RttyTheme {
RttyTheme._();
// ---- 基础色板 ----
static const Color background = Color(0xFF0B0E11); // 炭黑
static const Color surface = Color(0xFF12161B); // 面板
static const Color surfaceHi = Color(0xFF1A2027); // 高亮面板
static const Color border = Color(0xFF232B33); // 边框
static const Color primary = Color(0xFF3DDC97); // 磷光青绿
static const Color primaryDim = Color(0xFF1E7A5A);
static const Color accent = Color(0xFFFFB454); // 琥珀
static const Color danger = Color(0xFFE5534B); // 红
static const Color text = Color(0xFFD5DEE7);
static const Color textDim = Color(0xFF7B8A99);
static const Color textFaint = Color(0xFF4A5866);
static ThemeData app() {
final base = ThemeData.dark(useMaterial3: true);
return base.copyWith(
scaffoldBackgroundColor: background,
colorScheme: ColorScheme.dark(
primary: primary,
secondary: accent,
surface: surface,
error: danger,
onPrimary: const Color(0xFF06130D),
onSurface: text,
),
textTheme: base.textTheme.copyWith(
bodySmall: base.textTheme.bodySmall?.copyWith(color: textDim),
labelSmall: base.textTheme.labelSmall?.copyWith(
color: textFaint,
letterSpacing: 1.2,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
);
}
}

689
desktop/pubspec.lock Normal file
View File

@@ -0,0 +1,689 @@
# 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:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
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:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
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:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
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:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.9"
desktop_drop:
dependency: transitive
description:
name: desktop_drop
sha256: aa1e797255bfbc76f9eb5aa4f61e5b68dbf69962ab1be6495816d2f251bc0d1f
url: "https://pub.flutter-io.cn"
source: hosted
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:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
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"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.1"
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_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
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"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.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"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
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:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.flutter-io.cn"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
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:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
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:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.flutter-io.cn"
source: hosted
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"
process:
dependency: transitive
description:
name: process
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.0.5"
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
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.1"
sync_http:
dependency: transitive
description:
name: sync_http
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.3.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
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:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
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:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.flutter-io.cn"
source: hosted
version: "15.2.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: "direct main"
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.3"
webdriver:
dependency: transitive
description:
name: webdriver
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.flutter-io.cn"
source: hosted
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.44.0"

93
desktop/pubspec.yaml Normal file
View File

@@ -0,0 +1,93 @@
name: rtty_desktop
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.12.2
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
web_socket_channel: ^3.0.3
flutter_alacritty: ^2.4.0
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
integration_test:
sdk: flutter
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package

View File

@@ -0,0 +1,67 @@
// 端到端集成测试桌面端连接层RttyPtyBackend↔ Rust 服务端。
//
// 运行前需先启动 Rust 服务端cd .. && cargo run
// 运行flutter test test/e2e_ws_test.dart
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:rtty_desktop/src/rtty_pty_backend.dart';
import 'support.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
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 backend = RttyPtyBackend();
final states = <ConnState>[];
String? sessionId;
final raw = StringBuffer();
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 backend.connect(host: '127.0.0.1', port: 8080);
// 等待连接就绪socket 已连接 + 收到 ready 帧)。
final deadline = DateTime.now().add(const Duration(seconds: 5));
while ((backend.state != ConnState.connected || sessionId == null) &&
DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
expect(backend.state, ConnState.connected,
reason: '未能连接服务端 states=$states');
expect(sessionId, isNotNull, reason: '未收到 ready 帧 states=$states');
// 发送命令,等待回显。
backend.write(Uint8List.fromList(
'echo RTTY_E2E_${DateTime.now().millisecondsSinceEpoch}\r\n'.codeUnits));
String? matchedLine;
final outDeadline = DateTime.now().add(const Duration(seconds: 8));
while (DateTime.now().isBefore(outDeadline)) {
await Future<void>.delayed(const Duration(milliseconds: 150));
final text = raw.toString();
final lines = text.split('\n');
matchedLine = lines.where((l) => l.contains('RTTY_E2E')).lastOrNull;
if (matchedLine != null) break;
}
expect(matchedLine, isNotNull,
reason: '未捕获到命令回显。流末尾:'
'${raw.toString().length > 200 ? raw.toString().substring(raw.toString().length - 200) : raw.toString()}');
await backend.close();
expect(backend.state, ConnState.disconnected);
});
}

View File

@@ -0,0 +1,77 @@
// 多端解耦 + 控制权强制回归验证(连真实 Rust 服务端)。
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:web_socket_channel/io.dart';
import 'support.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('多端解耦 + 控制权强制 + 会话清理', () async {
if (!await isServerUp('127.0.0.1', 8080)) {
markTestSkipped('Rtty server not running');
return;
}
final base = 'ws://127.0.0.1:8080/ws';
// 1. 桌面端client=desktop收 raw 二进制,不收 snapshot。
final desktop = IOWebSocketChannel.connect(Uri.parse('$base?client=desktop'));
final dOut = <String>[];
desktop.stream.listen((m) {
if (m is List<int>) {
dOut.add('binary:${utf8.decode(m, allowMalformed: true)}');
} else if (m is String) {
dOut.add('text:${m.length > 100 ? m.substring(0, 100) : m}');
} else {
dOut.add('$m');
}
});
await Future<void>.delayed(const Duration(milliseconds: 700));
final dHasBinary = dOut.any((e) => e.startsWith('binary:'));
final dHasSnapshot = dOut.any((e) => e.contains('mobile_snapshot'));
expect(dHasBinary, isTrue, reason: '桌面端应收到原始 ANSI 二进制 $dOut');
expect(dHasSnapshot, isFalse, reason: '桌面端不应收到语义快照 $dOut');
// 2. 移动端client=mobile收 snapshot不收 binary。
final mobile = IOWebSocketChannel.connect(Uri.parse('$base?client=mobile'));
final mOut = <String>[];
mobile.stream.listen((m) {
if (m is List<int>) {
mOut.add('binary:${utf8.decode(m, allowMalformed: true)}');
} else if (m is String) {
mOut.add('text:${m.length > 100 ? m.substring(0, 100) : m}');
} else {
mOut.add('$m');
}
});
await Future<void>.delayed(const Duration(milliseconds: 700));
final mHasSnapshot = mOut.any((e) => e.contains('mobile_snapshot'));
final mHasBinary = mOut.any((e) => e.startsWith('binary:'));
expect(mHasSnapshot, isTrue, reason: '移动端应收到语义快照 $mOut');
expect(mHasBinary, isFalse, reason: '移动端不应收到二进制 $mOut');
// 3. 桌面端发命令,确认 raw 输出回显。
desktop.sink.add(jsonEncode({'type': 'input', 'data': 'echo D_E2E\r\n'}));
await Future<void>.delayed(const Duration(milliseconds: 900));
expect(dOut.join('\n').contains('D_E2E'), isTrue, reason: '桌面端命令回显');
// 4. 控制权强制:桌面端 claim 后,移动端输入被拒。
desktop.sink.add(jsonEncode({'type': 'claim_control'}));
await Future<void>.delayed(const Duration(milliseconds: 400));
mobile.sink
.add(jsonEncode({'type': 'input', 'data': 'echo BLOCKED_SHOULD_NOT_SHOW\r\n'}));
await Future<void>.delayed(const Duration(milliseconds: 900));
final both = (dOut.join('\n') + mOut.join('\n'));
expect(both.contains('BLOCKED_SHOULD_NOT_SHOW'), isFalse,
reason: '非控制者输入应被阻止');
// 5. 会话清理exit 触发会话移除。
desktop.sink.add(jsonEncode({'type': 'input', 'data': 'exit\r\n'}));
await Future<void>.delayed(const Duration(milliseconds: 1300));
desktop.sink.close();
mobile.sink.close();
});
}

14
desktop/test/support.dart Normal file
View File

@@ -0,0 +1,14 @@
// 集成测试共享工具。
import 'package:web_socket_channel/io.dart';
/// 探测 Rtty 服务端是否可达;返回 false 时集成测试应被跳过。
Future<bool> isServerUp(String host, int port) async {
try {
final ws = IOWebSocketChannel.connect(Uri.parse('ws://$host:$port/ws'));
await ws.ready.timeout(const Duration(seconds: 2));
ws.sink.close();
return true;
} catch (_) {
return false;
}
}

View File

@@ -0,0 +1,15 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rtty_desktop/main.dart';
import 'package:rtty_desktop/src/terminal_screen.dart';
void main() {
testWidgets('Rtty desktop renders terminal screen', (WidgetTester tester) async {
await tester.pumpWidget(const RttyApp());
// 未连接状态应显示连接提示与连接控制条。
expect(find.text('AWAITING CONNECTION'), findsOneWidget);
expect(find.text('CONNECT'), findsOneWidget);
expect(find.byType(TerminalScreen), findsOneWidget);
});
}

17
desktop/windows/.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
flutter/ephemeral/
# Visual Studio user-specific files.
*.suo
*.user
*.userosscache
*.sln.docstates
# Visual Studio build-related files.
x64/
x86/
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/

View File

@@ -0,0 +1,108 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.14)
project(rtty_desktop LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "rtty_desktop")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
cmake_policy(VERSION 3.14...3.25)
# Define build configuration option.
get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG)
if(IS_MULTICONFIG)
set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release"
CACHE STRING "" FORCE)
else()
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE "Debug" CACHE
STRING "Flutter build mode" FORCE)
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
"Debug" "Profile" "Release")
endif()
endif()
# Define settings for the Profile build mode.
set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}")
set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}")
set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}")
set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}")
# Use Unicode for all projects.
add_definitions(-DUNICODE -D_UNICODE)
# Compilation settings that should be applied to most targets.
#
# Be cautious about adding new options here, as plugins use this function by
# default. In most cases, you should add new options to specific targets instead
# of modifying this function.
function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_features(${TARGET} PUBLIC cxx_std_17)
target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100")
target_compile_options(${TARGET} PRIVATE /EHsc)
target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0")
target_compile_definitions(${TARGET} PRIVATE "$<$<CONFIG:Debug>:_DEBUG>")
endfunction()
# Flutter library and tool build rules.
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
add_subdirectory(${FLUTTER_MANAGED_DIR})
# Application build; see runner/CMakeLists.txt.
add_subdirectory("runner")
# Generated plugin build rules, which manage building the plugins and adding
# them to the application.
include(flutter/generated_plugins.cmake)
# === Installation ===
# Support files are copied into place next to the executable, so that it can
# run in place. This is done instead of making a separate bundle (as on Linux)
# so that building and running from within Visual Studio will work.
set(BUILD_BUNDLE_DIR "$<TARGET_FILE_DIR:${BINARY_NAME}>")
# Make the "install" step default, as it's required to run.
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1)
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif()
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}")
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
COMPONENT Runtime)
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
if(PLUGIN_BUNDLED_LIBRARIES)
install(FILES "${PLUGIN_BUNDLED_LIBRARIES}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()
# Copy the native assets provided by the build.dart from all packages.
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/")
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
# Fully re-copy the assets directory on each build to avoid having stale files
# from a previous install.
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
install(CODE "
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
" COMPONENT Runtime)
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
# Install the AOT library on non-Debug builds only.
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
CONFIGURATIONS Profile;Release
COMPONENT Runtime)

View File

@@ -0,0 +1,109 @@
# This file controls Flutter-level build steps. It should not be edited.
cmake_minimum_required(VERSION 3.14)
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
# Configuration provided via flutter tool.
include(${EPHEMERAL_DIR}/generated_config.cmake)
# TODO: Move the rest of this into files in ephemeral. See
# https://github.com/flutter/flutter/issues/57146.
set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper")
# Set fallback configurations for older versions of the flutter tool.
if (NOT DEFINED FLUTTER_TARGET_PLATFORM)
set(FLUTTER_TARGET_PLATFORM "windows-x64")
endif()
# === Flutter Library ===
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll")
# Published to parent scope for install step.
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE)
list(APPEND FLUTTER_LIBRARY_HEADERS
"flutter_export.h"
"flutter_windows.h"
"flutter_messenger.h"
"flutter_plugin_registrar.h"
"flutter_texture_registrar.h"
)
list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/")
add_library(flutter INTERFACE)
target_include_directories(flutter INTERFACE
"${EPHEMERAL_DIR}"
)
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib")
add_dependencies(flutter flutter_assemble)
# === Wrapper ===
list(APPEND CPP_WRAPPER_SOURCES_CORE
"core_implementations.cc"
"standard_codec.cc"
)
list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/")
list(APPEND CPP_WRAPPER_SOURCES_PLUGIN
"plugin_registrar.cc"
)
list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/")
list(APPEND CPP_WRAPPER_SOURCES_APP
"flutter_engine.cc"
"flutter_view_controller.cc"
)
list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/")
# Wrapper sources needed for a plugin.
add_library(flutter_wrapper_plugin STATIC
${CPP_WRAPPER_SOURCES_CORE}
${CPP_WRAPPER_SOURCES_PLUGIN}
)
apply_standard_settings(flutter_wrapper_plugin)
set_target_properties(flutter_wrapper_plugin PROPERTIES
POSITION_INDEPENDENT_CODE ON)
set_target_properties(flutter_wrapper_plugin PROPERTIES
CXX_VISIBILITY_PRESET hidden)
target_link_libraries(flutter_wrapper_plugin PUBLIC flutter)
target_include_directories(flutter_wrapper_plugin PUBLIC
"${WRAPPER_ROOT}/include"
)
add_dependencies(flutter_wrapper_plugin flutter_assemble)
# Wrapper sources needed for the runner.
add_library(flutter_wrapper_app STATIC
${CPP_WRAPPER_SOURCES_CORE}
${CPP_WRAPPER_SOURCES_APP}
)
apply_standard_settings(flutter_wrapper_app)
target_link_libraries(flutter_wrapper_app PUBLIC flutter)
target_include_directories(flutter_wrapper_app PUBLIC
"${WRAPPER_ROOT}/include"
)
add_dependencies(flutter_wrapper_app flutter_assemble)
# === Flutter tool backend ===
# _phony_ is a non-existent file to force this command to run every time,
# since currently there's no way to get a full input/output list from the
# flutter tool.
set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_")
set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE)
add_custom_command(
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN}
${CPP_WRAPPER_SOURCES_APP}
${PHONY_OUTPUT}
COMMAND ${CMAKE_COMMAND} -E env
${FLUTTER_TOOL_ENVIRONMENT}
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat"
${FLUTTER_TARGET_PLATFORM} $<CONFIG>
VERBATIM
)
add_custom_target(flutter_assemble DEPENDS
"${FLUTTER_LIBRARY}"
${FLUTTER_LIBRARY_HEADERS}
${CPP_WRAPPER_SOURCES_CORE}
${CPP_WRAPPER_SOURCES_PLUGIN}
${CPP_WRAPPER_SOURCES_APP}
)

View File

@@ -0,0 +1,17 @@
//
// Generated file. Do not edit.
//
// clang-format off
#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"));
}

View File

@@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter/plugin_registry.h>
// Registers Flutter plugins.
void RegisterPlugins(flutter::PluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_

View File

@@ -0,0 +1,28 @@
#
# Generated file, do not edit.
#
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)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)

View File

@@ -0,0 +1,40 @@
cmake_minimum_required(VERSION 3.14)
project(runner LANGUAGES CXX)
# Define the application target. To change its name, change BINARY_NAME in the
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
# work.
#
# Any new source files that you add to the application should be added here.
add_executable(${BINARY_NAME} WIN32
"flutter_window.cpp"
"main.cpp"
"utils.cpp"
"win32_window.cpp"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
"Runner.rc"
"runner.exe.manifest"
)
# Apply the standard set of build settings. This can be removed for applications
# that need different build settings.
apply_standard_settings(${BINARY_NAME})
# Add preprocessor definitions for the build version.
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"")
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}")
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}")
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}")
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}")
# Disable Windows macros that collide with C++ standard library functions.
target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX")
# Add dependency libraries and include directories. Add any application-specific
# dependencies here.
target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app)
target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib")
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)

View File

@@ -0,0 +1,121 @@
// Microsoft Visual C++ generated resource script.
//
#pragma code_page(65001)
#include "resource.h"
#define APSTUDIO_READONLY_SYMBOLS
/////////////////////////////////////////////////////////////////////////////
//
// Generated from the TEXTINCLUDE 2 resource.
//
#include "winres.h"
/////////////////////////////////////////////////////////////////////////////
#undef APSTUDIO_READONLY_SYMBOLS
/////////////////////////////////////////////////////////////////////////////
// English (United States) resources
#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU)
LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US
#ifdef APSTUDIO_INVOKED
/////////////////////////////////////////////////////////////////////////////
//
// TEXTINCLUDE
//
1 TEXTINCLUDE
BEGIN
"resource.h\0"
END
2 TEXTINCLUDE
BEGIN
"#include ""winres.h""\r\n"
"\0"
END
3 TEXTINCLUDE
BEGIN
"\r\n"
"\0"
END
#endif // APSTUDIO_INVOKED
/////////////////////////////////////////////////////////////////////////////
//
// Icon
//
// Icon with lowest ID value placed first to ensure application icon
// remains consistent on all systems.
IDI_APP_ICON ICON "resources\\app_icon.ico"
/////////////////////////////////////////////////////////////////////////////
//
// Version
//
#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD)
#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD
#else
#define VERSION_AS_NUMBER 1,0,0,0
#endif
#if defined(FLUTTER_VERSION)
#define VERSION_AS_STRING FLUTTER_VERSION
#else
#define VERSION_AS_STRING "1.0.0"
#endif
VS_VERSION_INFO VERSIONINFO
FILEVERSION VERSION_AS_NUMBER
PRODUCTVERSION VERSION_AS_NUMBER
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
#ifdef _DEBUG
FILEFLAGS VS_FF_DEBUG
#else
FILEFLAGS 0x0L
#endif
FILEOS VOS__WINDOWS32
FILETYPE VFT_APP
FILESUBTYPE 0x0L
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904e4"
BEGIN
VALUE "CompanyName", "com.example" "\0"
VALUE "FileDescription", "rtty_desktop" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "rtty_desktop" "\0"
VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
VALUE "OriginalFilename", "rtty_desktop.exe" "\0"
VALUE "ProductName", "rtty_desktop" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1252
END
END
#endif // English (United States) resources
/////////////////////////////////////////////////////////////////////////////
#ifndef APSTUDIO_INVOKED
/////////////////////////////////////////////////////////////////////////////
//
// Generated from the TEXTINCLUDE 3 resource.
//
/////////////////////////////////////////////////////////////////////////////
#endif // not APSTUDIO_INVOKED

View File

@@ -0,0 +1,71 @@
#include "flutter_window.h"
#include <optional>
#include "flutter/generated_plugin_registrant.h"
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
: project_(project) {}
FlutterWindow::~FlutterWindow() {}
bool FlutterWindow::OnCreate() {
if (!Win32Window::OnCreate()) {
return false;
}
RECT frame = GetClientArea();
// The size here must match the window dimensions to avoid unnecessary surface
// creation / destruction in the startup path.
flutter_controller_ = std::make_unique<flutter::FlutterViewController>(
frame.right - frame.left, frame.bottom - frame.top, project_);
// Ensure that basic setup of the controller was successful.
if (!flutter_controller_->engine() || !flutter_controller_->view()) {
return false;
}
RegisterPlugins(flutter_controller_->engine());
SetChildContent(flutter_controller_->view()->GetNativeWindow());
flutter_controller_->engine()->SetNextFrameCallback([&]() {
this->Show();
});
// Flutter can complete the first frame before the "show window" callback is
// registered. The following call ensures a frame is pending to ensure the
// window is shown. It is a no-op if the first frame hasn't completed yet.
flutter_controller_->ForceRedraw();
return true;
}
void FlutterWindow::OnDestroy() {
if (flutter_controller_) {
flutter_controller_ = nullptr;
}
Win32Window::OnDestroy();
}
LRESULT
FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept {
// Give Flutter, including plugins, an opportunity to handle window messages.
if (flutter_controller_) {
std::optional<LRESULT> result =
flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam,
lparam);
if (result) {
return *result;
}
}
switch (message) {
case WM_FONTCHANGE:
flutter_controller_->engine()->ReloadSystemFonts();
break;
}
return Win32Window::MessageHandler(hwnd, message, wparam, lparam);
}

View File

@@ -0,0 +1,33 @@
#ifndef RUNNER_FLUTTER_WINDOW_H_
#define RUNNER_FLUTTER_WINDOW_H_
#include <flutter/dart_project.h>
#include <flutter/flutter_view_controller.h>
#include <memory>
#include "win32_window.h"
// A window that does nothing but host a Flutter view.
class FlutterWindow : public Win32Window {
public:
// Creates a new FlutterWindow hosting a Flutter view running |project|.
explicit FlutterWindow(const flutter::DartProject& project);
virtual ~FlutterWindow();
protected:
// Win32Window:
bool OnCreate() override;
void OnDestroy() override;
LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam,
LPARAM const lparam) noexcept override;
private:
// The project to run.
flutter::DartProject project_;
// The Flutter instance hosted by this window.
std::unique_ptr<flutter::FlutterViewController> flutter_controller_;
};
#endif // RUNNER_FLUTTER_WINDOW_H_

View File

@@ -0,0 +1,43 @@
#include <flutter/dart_project.h>
#include <flutter/flutter_view_controller.h>
#include <windows.h>
#include "flutter_window.h"
#include "utils.h"
int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
_In_ wchar_t *command_line, _In_ int show_command) {
// Attach to console when present (e.g., 'flutter run') or create a
// new console when running with a debugger.
if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) {
CreateAndAttachConsole();
}
// Initialize COM, so that it is available for use in the library and/or
// plugins.
::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
flutter::DartProject project(L"data");
std::vector<std::string> command_line_arguments =
GetCommandLineArguments();
project.set_dart_entrypoint_arguments(std::move(command_line_arguments));
FlutterWindow window(project);
Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720);
if (!window.Create(L"rtty_desktop", origin, size)) {
return EXIT_FAILURE;
}
window.SetQuitOnClose(true);
::MSG msg;
while (::GetMessage(&msg, nullptr, 0, 0)) {
::TranslateMessage(&msg);
::DispatchMessage(&msg);
}
::CoUninitialize();
return EXIT_SUCCESS;
}

View File

@@ -0,0 +1,16 @@
//{{NO_DEPENDENCIES}}
// Microsoft Visual C++ generated include file.
// Used by Runner.rc
//
#define IDI_APP_ICON 101
// Next default values for new objects
//
#ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NEXT_RESOURCE_VALUE 102
#define _APS_NEXT_COMMAND_VALUE 40001
#define _APS_NEXT_CONTROL_VALUE 1001
#define _APS_NEXT_SYMED_VALUE 101
#endif
#endif

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 and Windows 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>

View File

@@ -0,0 +1,69 @@
#include "utils.h"
#include <flutter_windows.h>
#include <io.h>
#include <stdio.h>
#include <windows.h>
#include <iostream>
void CreateAndAttachConsole() {
if (::AllocConsole()) {
FILE *unused;
if (freopen_s(&unused, "CONOUT$", "w", stdout)) {
_dup2(_fileno(stdout), 1);
}
if (freopen_s(&unused, "CONOUT$", "w", stderr)) {
_dup2(_fileno(stdout), 2);
}
std::ios::sync_with_stdio();
FlutterDesktopResyncOutputStreams();
}
}
std::vector<std::string> GetCommandLineArguments() {
// Convert the UTF-16 command line arguments to UTF-8 for the Engine to use.
int argc;
wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc);
if (argv == nullptr) {
return std::vector<std::string>();
}
std::vector<std::string> command_line_arguments;
// Skip the first argument as it's the binary name.
for (int i = 1; i < argc; i++) {
command_line_arguments.push_back(Utf8FromUtf16(argv[i]));
}
::LocalFree(argv);
return command_line_arguments;
}
std::string Utf8FromUtf16(const wchar_t* utf16_string) {
if (utf16_string == nullptr) {
return std::string();
}
// First, find the length of the string with a safe upper bound (CWE-126).
// UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING.
int input_length = static_cast<int>(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS));
// Now use that bounded length to determine the required buffer size.
// When an explicit length is passed, WideCharToMultiByte does not include
// the null terminator in its returned size.
int target_length = ::WideCharToMultiByte(
CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
input_length, nullptr, 0, nullptr, nullptr);
std::string utf8_string;
if (target_length == 0 || static_cast<size_t>(target_length) > utf8_string.max_size()) {
return utf8_string;
}
utf8_string.resize(target_length);
int converted_length = ::WideCharToMultiByte(
CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
input_length, utf8_string.data(), target_length, nullptr, nullptr);
if (converted_length == 0) {
return std::string();
}
return utf8_string;
}

View File

@@ -0,0 +1,19 @@
#ifndef RUNNER_UTILS_H_
#define RUNNER_UTILS_H_
#include <string>
#include <vector>
// Creates a console for the process, and redirects stdout and stderr to
// it for both the runner and the Flutter library.
void CreateAndAttachConsole();
// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string
// encoded in UTF-8. Returns an empty std::string on failure.
std::string Utf8FromUtf16(const wchar_t* utf16_string);
// Gets the command line arguments passed in as a std::vector<std::string>,
// encoded in UTF-8. Returns an empty std::vector<std::string> on failure.
std::vector<std::string> GetCommandLineArguments();
#endif // RUNNER_UTILS_H_

View File

@@ -0,0 +1,288 @@
#include "win32_window.h"
#include <dwmapi.h>
#include <flutter_windows.h>
#include "resource.h"
namespace {
/// Window attribute that enables dark mode window decorations.
///
/// Redefined in case the developer's machine has a Windows SDK older than
/// version 10.0.22000.0.
/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute
#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
#endif
constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW";
/// Registry key for app theme preference.
///
/// A value of 0 indicates apps should use dark mode. A non-zero or missing
/// value indicates apps should use light mode.
constexpr const wchar_t kGetPreferredBrightnessRegKey[] =
L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize";
constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme";
// The number of Win32Window objects that currently exist.
static int g_active_window_count = 0;
using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd);
// Scale helper to convert logical scaler values to physical using passed in
// scale factor
int Scale(int source, double scale_factor) {
return static_cast<int>(source * scale_factor);
}
// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module.
// This API is only needed for PerMonitor V1 awareness mode.
void EnableFullDpiSupportIfAvailable(HWND hwnd) {
HMODULE user32_module = LoadLibraryA("User32.dll");
if (!user32_module) {
return;
}
auto enable_non_client_dpi_scaling =
reinterpret_cast<EnableNonClientDpiScaling*>(
GetProcAddress(user32_module, "EnableNonClientDpiScaling"));
if (enable_non_client_dpi_scaling != nullptr) {
enable_non_client_dpi_scaling(hwnd);
}
FreeLibrary(user32_module);
}
} // namespace
// Manages the Win32Window's window class registration.
class WindowClassRegistrar {
public:
~WindowClassRegistrar() = default;
// Returns the singleton registrar instance.
static WindowClassRegistrar* GetInstance() {
if (!instance_) {
instance_ = new WindowClassRegistrar();
}
return instance_;
}
// Returns the name of the window class, registering the class if it hasn't
// previously been registered.
const wchar_t* GetWindowClass();
// Unregisters the window class. Should only be called if there are no
// instances of the window.
void UnregisterWindowClass();
private:
WindowClassRegistrar() = default;
static WindowClassRegistrar* instance_;
bool class_registered_ = false;
};
WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr;
const wchar_t* WindowClassRegistrar::GetWindowClass() {
if (!class_registered_) {
WNDCLASS window_class{};
window_class.hCursor = LoadCursor(nullptr, IDC_ARROW);
window_class.lpszClassName = kWindowClassName;
window_class.style = CS_HREDRAW | CS_VREDRAW;
window_class.cbClsExtra = 0;
window_class.cbWndExtra = 0;
window_class.hInstance = GetModuleHandle(nullptr);
window_class.hIcon =
LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON));
window_class.hbrBackground = 0;
window_class.lpszMenuName = nullptr;
window_class.lpfnWndProc = Win32Window::WndProc;
RegisterClass(&window_class);
class_registered_ = true;
}
return kWindowClassName;
}
void WindowClassRegistrar::UnregisterWindowClass() {
UnregisterClass(kWindowClassName, nullptr);
class_registered_ = false;
}
Win32Window::Win32Window() {
++g_active_window_count;
}
Win32Window::~Win32Window() {
--g_active_window_count;
Destroy();
}
bool Win32Window::Create(const std::wstring& title,
const Point& origin,
const Size& size) {
Destroy();
const wchar_t* window_class =
WindowClassRegistrar::GetInstance()->GetWindowClass();
const POINT target_point = {static_cast<LONG>(origin.x),
static_cast<LONG>(origin.y)};
HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST);
UINT dpi = FlutterDesktopGetDpiForMonitor(monitor);
double scale_factor = dpi / 96.0;
HWND window = CreateWindow(
window_class, title.c_str(), WS_OVERLAPPEDWINDOW,
Scale(origin.x, scale_factor), Scale(origin.y, scale_factor),
Scale(size.width, scale_factor), Scale(size.height, scale_factor),
nullptr, nullptr, GetModuleHandle(nullptr), this);
if (!window) {
return false;
}
UpdateTheme(window);
return OnCreate();
}
bool Win32Window::Show() {
return ShowWindow(window_handle_, SW_SHOWNORMAL);
}
// static
LRESULT CALLBACK Win32Window::WndProc(HWND const window,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept {
if (message == WM_NCCREATE) {
auto window_struct = reinterpret_cast<CREATESTRUCT*>(lparam);
SetWindowLongPtr(window, GWLP_USERDATA,
reinterpret_cast<LONG_PTR>(window_struct->lpCreateParams));
auto that = static_cast<Win32Window*>(window_struct->lpCreateParams);
EnableFullDpiSupportIfAvailable(window);
that->window_handle_ = window;
} else if (Win32Window* that = GetThisFromHandle(window)) {
return that->MessageHandler(window, message, wparam, lparam);
}
return DefWindowProc(window, message, wparam, lparam);
}
LRESULT
Win32Window::MessageHandler(HWND hwnd,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept {
switch (message) {
case WM_DESTROY:
window_handle_ = nullptr;
Destroy();
if (quit_on_close_) {
PostQuitMessage(0);
}
return 0;
case WM_DPICHANGED: {
auto newRectSize = reinterpret_cast<RECT*>(lparam);
LONG newWidth = newRectSize->right - newRectSize->left;
LONG newHeight = newRectSize->bottom - newRectSize->top;
SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth,
newHeight, SWP_NOZORDER | SWP_NOACTIVATE);
return 0;
}
case WM_SIZE: {
RECT rect = GetClientArea();
if (child_content_ != nullptr) {
// Size and position the child window.
MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left,
rect.bottom - rect.top, TRUE);
}
return 0;
}
case WM_ACTIVATE:
if (child_content_ != nullptr) {
SetFocus(child_content_);
}
return 0;
case WM_DWMCOLORIZATIONCOLORCHANGED:
UpdateTheme(hwnd);
return 0;
}
return DefWindowProc(window_handle_, message, wparam, lparam);
}
void Win32Window::Destroy() {
OnDestroy();
if (window_handle_) {
DestroyWindow(window_handle_);
window_handle_ = nullptr;
}
if (g_active_window_count == 0) {
WindowClassRegistrar::GetInstance()->UnregisterWindowClass();
}
}
Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept {
return reinterpret_cast<Win32Window*>(
GetWindowLongPtr(window, GWLP_USERDATA));
}
void Win32Window::SetChildContent(HWND content) {
child_content_ = content;
SetParent(content, window_handle_);
RECT frame = GetClientArea();
MoveWindow(content, frame.left, frame.top, frame.right - frame.left,
frame.bottom - frame.top, true);
SetFocus(child_content_);
}
RECT Win32Window::GetClientArea() {
RECT frame;
GetClientRect(window_handle_, &frame);
return frame;
}
HWND Win32Window::GetHandle() {
return window_handle_;
}
void Win32Window::SetQuitOnClose(bool quit_on_close) {
quit_on_close_ = quit_on_close;
}
bool Win32Window::OnCreate() {
// No-op; provided for subclasses.
return true;
}
void Win32Window::OnDestroy() {
// No-op; provided for subclasses.
}
void Win32Window::UpdateTheme(HWND const window) {
DWORD light_mode;
DWORD light_mode_size = sizeof(light_mode);
LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey,
kGetPreferredBrightnessRegValue,
RRF_RT_REG_DWORD, nullptr, &light_mode,
&light_mode_size);
if (result == ERROR_SUCCESS) {
BOOL enable_dark_mode = light_mode == 0;
DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE,
&enable_dark_mode, sizeof(enable_dark_mode));
}
}

View File

@@ -0,0 +1,102 @@
#ifndef RUNNER_WIN32_WINDOW_H_
#define RUNNER_WIN32_WINDOW_H_
#include <windows.h>
#include <functional>
#include <memory>
#include <string>
// A class abstraction for a high DPI-aware Win32 Window. Intended to be
// inherited from by classes that wish to specialize with custom
// rendering and input handling
class Win32Window {
public:
struct Point {
unsigned int x;
unsigned int y;
Point(unsigned int x, unsigned int y) : x(x), y(y) {}
};
struct Size {
unsigned int width;
unsigned int height;
Size(unsigned int width, unsigned int height)
: width(width), height(height) {}
};
Win32Window();
virtual ~Win32Window();
// Creates a win32 window with |title| that is positioned and sized using
// |origin| and |size|. New windows are created on the default monitor. Window
// sizes are specified to the OS in physical pixels, hence to ensure a
// consistent size this function will scale the inputted width and height as
// as appropriate for the default monitor. The window is invisible until
// |Show| is called. Returns true if the window was created successfully.
bool Create(const std::wstring& title, const Point& origin, const Size& size);
// Show the current window. Returns true if the window was successfully shown.
bool Show();
// Release OS resources associated with window.
void Destroy();
// Inserts |content| into the window tree.
void SetChildContent(HWND content);
// Returns the backing Window handle to enable clients to set icon and other
// window properties. Returns nullptr if the window has been destroyed.
HWND GetHandle();
// If true, closing this window will quit the application.
void SetQuitOnClose(bool quit_on_close);
// Return a RECT representing the bounds of the current client area.
RECT GetClientArea();
protected:
// Processes and route salient window messages for mouse handling,
// size change and DPI. Delegates handling of these to member overloads that
// inheriting classes can handle.
virtual LRESULT MessageHandler(HWND window,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept;
// Called when CreateAndShow is called, allowing subclass window-related
// setup. Subclasses should return false if setup fails.
virtual bool OnCreate();
// Called when Destroy is called.
virtual void OnDestroy();
private:
friend class WindowClassRegistrar;
// OS callback called by message pump. Handles the WM_NCCREATE message which
// is passed when the non-client area is being created and enables automatic
// non-client DPI scaling so that the non-client area automatically
// responds to changes in DPI. All other messages are handled by
// MessageHandler.
static LRESULT CALLBACK WndProc(HWND const window,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept;
// Retrieves a class instance pointer for |window|
static Win32Window* GetThisFromHandle(HWND const window) noexcept;
// Update the window frame's theme to match the system theme.
static void UpdateTheme(HWND const window);
bool quit_on_close_ = false;
// window handle for top level window.
HWND window_handle_ = nullptr;
// window handle for hosted content.
HWND child_content_ = nullptr;
};
#endif // RUNNER_WIN32_WINDOW_H_

View File

@@ -0,0 +1,123 @@
//! 服务端配置模块。
//!
//! 支持通过环境变量(`RTTY_HOST`、`RTTY_PORT`、`RTTY_SHELL` 等)覆盖默认值,
//! 未来可扩展为从配置文件加载。
use std::env;
/// Rtty 服务端配置。
#[derive(Debug, Clone)]
pub struct ServerConfig {
/// 监听地址。
pub host: String,
/// 监听端口。
pub port: u16,
/// 默认启动的 Shell 程序。
pub shell: String,
/// 终端默认列数。
pub cols: u16,
/// 终端默认行数。
pub rows: u16,
/// 回滚缓冲区的最大行数。
pub max_scrollback: usize,
/// 单个会话允许的最大并发客户端数0 表示不限制)。
pub max_clients: usize,
/// 会话在无客户端连接后保留的秒数;超时则清理(支持断线重连窗口)。
pub idle_timeout_secs: u64,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: "0.0.0.0".into(),
port: 8080,
shell: default_shell(),
cols: 120,
rows: 32,
max_scrollback: 10_000,
max_clients: 16,
idle_timeout_secs: 60,
}
}
}
impl ServerConfig {
/// 从环境变量加载配置,未设置的项回落到默认值。
pub fn from_env() -> Self {
let mut cfg = ServerConfig::default();
if let Ok(v) = env::var("RTTY_HOST") {
cfg.host = v;
}
if let Ok(v) = env::var("RTTY_PORT")
&& let Ok(p) = v.parse()
{
cfg.port = p;
}
if let Ok(v) = env::var("RTTY_SHELL") {
cfg.shell = v;
}
if let Ok(v) = env::var("RTTY_COLS")
&& let Ok(c) = v.parse()
{
cfg.cols = c;
}
if let Ok(v) = env::var("RTTY_ROWS")
&& let Ok(r) = v.parse()
{
cfg.rows = r;
}
if let Ok(v) = env::var("RTTY_MAX_SCROLLBACK")
&& let Ok(n) = v.parse()
{
cfg.max_scrollback = n;
}
if let Ok(v) = env::var("RTTY_MAX_CLIENTS")
&& let Ok(n) = v.parse()
{
cfg.max_clients = n;
}
if let Ok(v) = env::var("RTTY_IDLE_TIMEOUT")
&& let Ok(n) = v.parse()
{
cfg.idle_timeout_secs = n;
}
cfg
}
/// 返回 `host:port` 形式的监听地址。
pub fn bind_addr(&self) -> String {
format!("{}:{}", self.host, self.port)
}
}
/// 根据当前平台选择默认 Shell。
fn default_shell() -> String {
#[cfg(windows)]
{
env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".into())
}
#[cfg(not(windows))]
{
for shell in ["zsh", "bash", "sh"] {
if command_exists(shell) {
return shell.to_string();
}
}
"sh".to_string()
}
}
/// 检查某个命令是否存在于 PATH 中。
#[cfg(not(windows))]
fn command_exists(cmd: &str) -> bool {
use std::process::Command;
Command::new(cmd)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}

View File

@@ -2,33 +2,51 @@ mod config;
mod terminal;
mod ws;
use axum::{routing::get, Router};
use std::sync::Arc;
use axum::{routing::get, Router};
use dashmap::DashMap;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use config::ServerConfig;
use ws::handler::Session;
/// 全局应用状态。
pub struct AppState {
// 预留全局 Session 管理句柄
/// 会话表session_id -> Session
pub sessions: DashMap<String, Arc<Session>>,
/// 服务端配置。
pub config: Arc<ServerConfig>,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 初始化日志
// 初始化日志
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "rtty_server=debug".into()))
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "rtty_server=debug,tower_http=info".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let state = Arc::new(AppState {});
let config = Arc::new(ServerConfig::from_env());
let state = Arc::new(AppState {
sessions: DashMap::new(),
config: config.clone(),
});
let app = Router::new()
.route("/ws", get(ws::handler::ws_route))
.with_state(state);
let addr = "0.0.0.0:8080";
let addr = config.bind_addr();
tracing::info!("🚀 Rtty Server running on ws://{}", addr);
tracing::info!(" shell: {}", config.shell);
tracing::info!(" default size: {}x{}", config.cols, config.rows);
let listener = tokio::net::TcpListener::bind(addr).await?;
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
}

View File

@@ -0,0 +1,151 @@
//! 终端状态引擎。
//!
//! 封装 [`alacritty_terminal::Term`] 作为整个系统的“真相源”,负责:
//! - 将 PTY 读到的原始字节流喂给 ANSI 解析器,维护 2D 屏幕网格与滚动历史;
//! - 把终端回写给 Shell 的数据(如光标位置上报、标题查询响应)转发到 PTY
//! - 为移动端生成语义化 JSON 快照,为断线重连提供状态恢复。
use std::io::Write;
use std::sync::{Arc, Mutex};
use alacritty_terminal::event::{Event, EventListener};
use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::index::{Column, Point};
use alacritty_terminal::term::cell::Flags;
use alacritty_terminal::term::{point_to_viewport, viewport_to_point, Config, Term};
use alacritty_terminal::vte::ansi::Processor;
use crate::ws::protocol::MobileSnapshot;
/// 终端尺寸,实现 alacritty 的 [`Dimensions`]。
#[derive(Debug, Clone, Copy)]
pub struct TermSize {
pub columns: usize,
pub rows: usize,
}
impl Dimensions for TermSize {
fn total_lines(&self) -> usize {
self.rows
}
fn screen_lines(&self) -> usize {
self.rows
}
fn columns(&self) -> usize {
self.columns
}
}
/// 事件监听器:把 alacritty 要求回写给 Shell 的数据写入 PTY。
#[derive(Clone)]
pub struct SessionListener {
writer: Arc<Mutex<Box<dyn Write + Send>>>,
}
impl SessionListener {
pub fn new(writer: Arc<Mutex<Box<dyn Write + Send>>>) -> Self {
Self { writer }
}
}
impl EventListener for SessionListener {
fn send_event(&self, event: Event) {
if let Event::PtyWrite(text) = event {
let mut w = match self.writer.lock() {
Ok(w) => w,
Err(_) => return,
};
let _ = w.write_all(text.as_bytes());
let _ = w.flush();
}
}
}
/// 终端引擎:持有 `Term` 与 ANSI 解析器。
pub struct TerminalEngine {
term: Term<SessionListener>,
parser: Processor,
}
impl TerminalEngine {
/// 创建一个新的终端引擎。
///
/// `scrollback` 为滚动历史的行数上限,对应 alacritty 的 `scrolling_history`。
pub fn new(
size: TermSize,
writer: Arc<Mutex<Box<dyn Write + Send>>>,
scrollback: usize,
) -> Self {
let listener = SessionListener::new(writer);
let config = Config { scrolling_history: scrollback, ..Config::default() };
let term = Term::new(config, &size, listener);
Self { term, parser: Processor::new() }
}
/// 将 PTY 读到的字节流喂给解析器。
pub fn feed(&mut self, bytes: &[u8]) {
for &byte in bytes {
self.parser.advance(&mut self.term, byte);
}
}
/// 调整终端网格尺寸。
pub fn resize(&mut self, cols: u16, rows: u16) {
let size = TermSize { columns: cols as usize, rows: rows as usize };
self.term.resize(size);
}
/// 生成移动端语义化快照。
pub fn snapshot(&self) -> MobileSnapshot {
let grid = self.term.grid();
let columns = grid.columns();
let rows = grid.screen_lines();
let display_offset = grid.display_offset();
let cursor_point = grid.cursor.point;
let cursor = point_to_viewport(display_offset, cursor_point);
let mut lines = Vec::with_capacity(rows);
for line_idx in 0..rows {
// 将视口内逻辑行号转换为网格行号。
let grid_point = viewport_to_point(display_offset, Point::new(line_idx, Column(0)));
let row = &grid[grid_point.line];
let mut text = String::new();
for cell in row {
// 跳过全角字符的占位空格,避免语义化文本中出现多余空白。
if cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
continue;
}
// 跳过空格的连续尾部会在上层处理;这里仍收集可见字符。
if cell.c != ' ' || !text.is_empty() {
text.push(cell.c);
}
}
lines.push(text.trim_end().to_string());
}
MobileSnapshot {
cursor_x: cursor.map(|c| c.column.0).unwrap_or(0),
cursor_y: cursor.map(|c| c.line).unwrap_or(0),
cols: columns,
rows,
display_offset,
scrollback_lines: grid.history_size(),
lines,
}
}
/// 当前网格列数。
pub fn columns(&self) -> usize {
self.term.grid().columns()
}
/// 当前网格行数。
pub fn rows(&self) -> usize {
self.term.grid().screen_lines()
}
}

View File

@@ -1,2 +1,2 @@
mod pty;
mod engine;
pub mod engine;
pub mod pty;

View File

@@ -0,0 +1,98 @@
//! PTY 会话封装。
//!
//! 基于 [`portable_pty`] 创建伪终端并拉起子进程Shell提供读写与 resize 能力。
//! 读取端由服务端读取任务独占持有,写入端与 master 句柄可被多个客户端共享。
use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use portable_pty::{Child, MasterPty, PtyPair, PtySize};
/// 一个已启动的 PTY 会话。
pub struct PtySession {
/// Master 端句柄,用于 resize / 获取大小。
master: Box<dyn MasterPty + Send>,
/// 从 Slave 端读取输出的流。独占,由读取任务持有。
reader: Option<Box<dyn Read + Send>>,
/// 写入 Slave 端的流。可被多个客户端共享(加锁)。
writer: Arc<Mutex<Box<dyn Write + Send>>>,
/// 子进程句柄,用于检测退出 / 终止。
child: Box<dyn Child + Send + Sync>,
/// 当前尺寸。
size: PtySize,
}
impl PtySession {
/// 以指定 Shell 与初始尺寸创建一个 PTY 会话。
pub fn new(shell: &str, cols: u16, rows: u16) -> Result<Self> {
let size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
let pty_system = portable_pty::native_pty_system();
let pair = pty_system.openpty(size).context("failed to open pty")?;
let child = spawn_child(&pair, shell).context("failed to spawn shell")?;
let reader = pair.master.try_clone_reader().context("failed to clone pty reader")?;
let writer = pair.master.take_writer().context("failed to take pty writer")?;
Ok(Self {
master: pair.master,
reader: Some(reader),
writer: Arc::new(Mutex::new(writer)),
child,
size,
})
}
/// 取出读取端,供 PTY 读取任务独占使用。取走后不可再次调用。
pub fn take_reader(&mut self) -> Option<Box<dyn Read + Send>> {
self.reader.take()
}
/// 获取共享写入端。
pub fn writer(&self) -> &Arc<Mutex<Box<dyn Write + Send>>> {
&self.writer
}
/// 将数据写入 Slave 端(发送给 Shell
pub fn write(&self, data: &[u8]) -> Result<()> {
let mut w = self
.writer
.lock()
.map_err(|_| anyhow::anyhow!("pty writer poisoned"))?;
w.write_all(data)?;
w.flush()?;
Ok(())
}
/// 调整 PTY 尺寸(通知内核与子进程)。
pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
self.size.rows = rows;
self.size.cols = cols;
self.master.resize(self.size).context("failed to resize pty")
}
/// 当前 PTY 尺寸。
pub fn size(&self) -> (u16, u16) {
(self.size.cols, self.size.rows)
}
/// 子进程是否已退出。
pub fn try_wait(&mut self) -> Result<Option<portable_pty::ExitStatus>> {
self.child.try_wait().context("failed to poll child")
}
/// 终止子进程。
pub fn kill(&mut self) -> Result<()> {
self.child.kill().context("failed to kill child")
}
}
/// 拉起子进程,并把 Stdio 重定向到 PTY 的 Slave 端。
fn spawn_child(pair: &PtyPair, shell: &str) -> Result<Box<dyn Child + Send + Sync>> {
let mut cmd = portable_pty::CommandBuilder::new(shell);
// 让 Shell 以交互方式运行。
cmd.env("TERM", "xterm-256color");
let child = pair.slave.spawn_command(cmd)?;
Ok(child)
}

View File

@@ -0,0 +1,368 @@
//! WebSocket 处理器与会话管理。
//!
//! 每个会话对应一个 PTY + 一个 alacritty 终端引擎。PTY 读取任务将输出广播给所有
//! 订阅者PC 端收原始 ANSI 二进制帧,移动端收 JSON 快照)。客户端消息写入 PTY。
use std::collections::HashMap;
use std::io::Read;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use anyhow::Result;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Query, State};
use axum::response::IntoResponse;
use futures_util::{SinkExt, StreamExt};
use tokio::sync::broadcast;
use crate::config::ServerConfig;
use crate::terminal::engine::{TermSize, TerminalEngine};
use crate::terminal::pty::PtySession;
use crate::ws::protocol::{ClientMessage, MobileSnapshot, ServerMessage};
/// 会话全局 ID 计数器。
static SESSION_SEQ: AtomicU64 = AtomicU64::new(0);
/// 客户端全局 ID 计数器。
static CLIENT_SEQ: AtomicU64 = AtomicU64::new(0);
/// 客户端类型:决定它订阅哪一路输出流(多端解耦)。
#[derive(Clone, Copy, PartialEq, Eq)]
enum ClientKind {
/// 桌面端:订阅原始 ANSI 二进制流。
Desktop,
/// 移动端:订阅语义化 JSON 快照。
Mobile,
}
impl ClientKind {
/// 从 WebSocket 查询参数解析客户端类型,默认桌面端。
fn from_params(params: &HashMap<String, String>) -> Self {
match params.get("client").map(|s| s.as_str()) {
Some("mobile") => Self::Mobile,
_ => Self::Desktop,
}
}
}
/// 广播给订阅者的输出事件。
#[derive(Clone)]
pub enum OutputEvent {
/// 原始 ANSI 字节流PC 端渲染)。
Raw(Vec<u8>),
/// 语义化快照(移动端渲染)。
Snapshot(MobileSnapshot),
/// PTY 已关闭。
Exit,
}
/// 一个终端会话。
pub struct Session {
pub id: String,
/// PTY 会话(写入 / resize / kill
pub pty: Arc<Mutex<PtySession>>,
/// alacritty 终端引擎。
pub engine: Arc<Mutex<TerminalEngine>>,
/// 输出广播通道。
pub output: broadcast::Sender<OutputEvent>,
/// 当前持有控制权的客户端 ID。
pub control: Arc<Mutex<Option<String>>>,
/// 当前连接的客户端数。
pub clients: AtomicUsize,
}
/// WebSocket 路由入口。
pub async fn ws_route(
ws: WebSocketUpgrade,
State(state): State<Arc<crate::AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state, params))
}
/// 建立连接并处理双向消息。
async fn handle_socket(
socket: WebSocket,
state: Arc<crate::AppState>,
params: HashMap<String, String>,
) {
let client_id = format!("client-{}", CLIENT_SEQ.fetch_add(1, Ordering::Relaxed));
let kind = ClientKind::from_params(&params);
let requested = params.get("session").cloned().unwrap_or_default();
let session = match get_or_create_session(&state, &requested) {
Ok(s) => s,
Err(e) => {
let msg = ServerMessage::Error { message: format!("session error: {e}") };
let _ = send_text(socket, msg.to_json()).await;
return;
}
};
let (mut tx, mut rx) = socket.split();
// 发送就绪消息。
let (cols, rows) = session.pty.lock().unwrap().size();
let ready = ServerMessage::Ready { id: session.id.clone(), cols, rows };
if tx.send(Message::Text(ready.to_json())).await.is_err() {
return;
}
// 移动端在连接时获取一次语义化快照作为初始状态(桌面端走原始 ANSI 流)。
if kind == ClientKind::Mobile {
let snap = session.engine.lock().unwrap().snapshot();
let init = ServerMessage::MobileSnapshot { data: snap };
if tx.send(Message::Text(init.to_json())).await.is_err() {
return;
}
}
// 订阅前检查并发上限。
let max_clients = state.config.max_clients;
if max_clients > 0 && session.clients.load(Ordering::SeqCst) >= max_clients {
let msg = ServerMessage::Error {
message: format!("session {} is at max client capacity", session.id),
};
let _ = tx.send(Message::Text(msg.to_json())).await;
return;
}
// 订阅输出。
let mut out_rx = session.output.subscribe();
session.clients.fetch_add(1, Ordering::SeqCst);
loop {
tokio::select! {
// 服务端输出 -> 客户端(按客户端类型过滤,实现多端解耦)。
out = out_rx.recv() => {
match out {
Ok(OutputEvent::Raw(bytes)) => {
if kind == ClientKind::Desktop
&& tx.send(Message::Binary(bytes)).await.is_err()
{
break;
}
}
Ok(OutputEvent::Snapshot(s)) => {
if kind == ClientKind::Mobile {
let msg = ServerMessage::MobileSnapshot { data: s };
if tx.send(Message::Text(msg.to_json())).await.is_err() {
break;
}
}
}
Ok(OutputEvent::Exit) => {
let _ = tx.send(Message::Text(ServerMessage::SessionClosed.to_json())).await;
break;
}
Err(_) => break,
}
}
// 客户端输入 -> 服务端
msg = rx.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
if let Some(reply) = handle_client_message(&session, &client_id, &text)
&& tx.send(Message::Text(reply)).await.is_err()
{
break;
}
}
Some(Ok(Message::Binary(bytes))) => {
// 兼容:客户端也可能用二进制发送输入。
let _ = session.pty.lock().unwrap().write(&bytes);
}
Some(Ok(_)) => {}
Some(Err(_)) | None => break,
}
}
}
}
session.clients.fetch_sub(1, Ordering::SeqCst);
// 若已无客户端安排空闲超时清理兜底机制Windows ConPTY 下进程退出
// 检测不可靠,依赖超时确保会话最终被释放)。
if session.clients.load(Ordering::SeqCst) == 0 {
spawn_idle_cleanup(session, state);
}
}
/// 空闲超时清理:若无客户端重连,则终止会话并释放资源。
fn spawn_idle_cleanup(session: Arc<Session>, state: Arc<crate::AppState>) {
let timeout = state.config.idle_timeout_secs;
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(timeout)).await;
if session.clients.load(Ordering::SeqCst) == 0 {
cleanup_session(&session, &state);
}
});
}
/// 处理单条客户端 JSON 消息,返回需要回发给该客户端的 JSON若无则 None
fn handle_client_message(
session: &Session,
client_id: &str,
text: &str,
) -> Option<String> {
let msg: ClientMessage = match serde_json::from_str(text) {
Ok(m) => m,
Err(_) => return None,
};
match msg {
ClientMessage::Input { data } => {
// 控制权强制:若存在控制者且不是当前客户端,则拒绝写入,
// 避免多端同时输入互相干扰。
let blocked = {
let control = session.control.lock().unwrap();
control.as_deref().is_some_and(|h| h != client_id)
};
if !blocked {
let _ = session.pty.lock().unwrap().write(data.as_bytes());
}
None
}
ClientMessage::Resize { cols, rows } => {
// 先调整 PTY 内核尺寸,再调整终端网格。
let mut pty = session.pty.lock().unwrap();
let _ = pty.resize(cols, rows);
drop(pty);
session.engine.lock().unwrap().resize(cols, rows);
None
}
ClientMessage::ClaimControl => {
let mut control = session.control.lock().unwrap();
let granted = control.is_none();
if granted {
*control = Some(client_id.to_string());
}
let holder = control.clone();
drop(control);
Some(ServerMessage::ControlResponse { granted, holder }.to_json())
}
ClientMessage::Ping => None,
}
}
/// 获取或创建会话。
fn get_or_create_session(
state: &Arc<crate::AppState>,
requested: &str,
) -> Result<Arc<Session>> {
if !requested.is_empty()
&& let Some(s) = state.sessions.get(requested)
{
return Ok(s.clone());
}
let id = format!("session-{}", SESSION_SEQ.fetch_add(1, Ordering::Relaxed));
let session = create_session(id.clone(), &state.config, state.clone())?;
state.sessions.insert(id.clone(), session.clone());
Ok(session)
}
/// 创建会话并启动 PTY 读取任务。
fn create_session(
id: String,
config: &ServerConfig,
state: Arc<crate::AppState>,
) -> Result<Arc<Session>> {
let mut pty = PtySession::new(&config.shell, config.cols, config.rows)?;
let writer = pty.writer().clone();
let engine = Arc::new(Mutex::new(TerminalEngine::new(
TermSize { columns: config.cols as usize, rows: config.rows as usize },
writer,
config.max_scrollback,
)));
let reader = pty.take_reader().ok_or_else(|| anyhow::anyhow!("pty reader already taken"))?;
let (output, _) = broadcast::channel(4096);
let session = Arc::new(Session {
id,
pty: Arc::new(Mutex::new(pty)),
engine,
output,
control: Arc::new(Mutex::new(None)),
clients: AtomicUsize::new(0),
});
start_pty_reader(session.clone(), reader, state);
Ok(session)
}
/// 启动阻塞的 PTY 读取任务,读取输出并广播。
///
/// 同时启动一个进程监控任务,通过轮询 `try_wait` 检测子进程退出——
/// 因为 Windows ConPTY 下 shell 退出可能不触发 PTY EOF仅靠 EOF 无法可靠清理。
fn start_pty_reader(
session: Arc<Session>,
mut reader: Box<dyn Read + Send>,
state: Arc<crate::AppState>,
) {
// 进程退出监控:一旦子进程退出即清理会话。
spawn_exit_monitor(session.clone(), state.clone());
tokio::task::spawn_blocking(move || {
let mut buf = vec![0u8; 8192];
loop {
let n = match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => n,
Err(_) => break,
};
let chunk = &buf[..n];
// 喂给解析器并生成快照。
let snapshot = {
let mut eng = match session.engine.lock() {
Ok(e) => e,
Err(_) => break,
};
eng.feed(chunk);
eng.snapshot()
};
// 广播原始字节 + 快照。
let _ = session.output.send(OutputEvent::Raw(chunk.to_vec()));
let _ = session.output.send(OutputEvent::Snapshot(snapshot));
}
// PTY 读到 EOFUnix 场景):触发会话结束并清理。
cleanup_session(&session, &state);
});
}
/// 轮询子进程退出状态,退出后清理会话。
fn spawn_exit_monitor(session: Arc<Session>, state: Arc<crate::AppState>) {
tokio::task::spawn_blocking(move || {
// 最多监控 5 分钟,避免无意义长驻。
for _ in 0..3000 {
let exited = match session.pty.lock() {
Ok(mut pty) => pty.try_wait().map(|s| s.is_some()).unwrap_or(false),
Err(_) => false,
};
if exited {
cleanup_session(&session, &state);
return;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
});
}
/// 触发会话结束事件并清理资源(幂等)。
fn cleanup_session(session: &Session, state: &crate::AppState) {
let _ = session.output.send(OutputEvent::Exit);
if let Ok(mut pty) = session.pty.lock() {
let _ = pty.kill();
}
state.sessions.remove(&session.id);
tracing::info!("session {} closed", session.id);
}
/// 发送一条文本消息,忽略错误。
async fn send_text(socket: WebSocket, text: String) -> Result<()> {
let (mut tx, _rx) = socket.split();
tx.send(Message::Text(text)).await?;
Ok(())
}

View File

@@ -1,2 +1,2 @@
pub(crate) mod handler;
mod protocol;
pub mod protocol;

View File

@@ -1,27 +1,77 @@
//! WebSocket 通信协议。
//!
//! 服务端与客户端之间通过 WebSocket 交换消息:
//! - 客户端发送 [`ClientMessage`](输入、调整尺寸、声明控制权);
//! - 服务端发送 [`ServerMessage`](就绪、移动端快照、控制权授予、错误、会话结束)。
//!
//! 另外,为 PC 端提供**二进制**通道PTY 输出的原始 ANSI 字节流直接以 WebSocket
//! 二进制帧下发,供 `flutter_alacritty` 等渲染引擎消费,保证 100% 工业级兼容。
use serde::{Deserialize, Serialize};
/// 客户端发送给 Rtty 服务端的控制指令
/// 客户端发送给 Rtty 服务端的控制指令
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
/// 键盘/文本输入
/// 键盘/文本输入,原样写入 PTY。
Input { data: String },
/// 客户端请求 Resize
/// 客户端请求调整终端尺寸。
Resize { cols: u16, rows: u16 },
/// 客户端申明控制权 (用来解决多端控制冲突)
/// 客户端申明控制权用来解决多端控制冲突)。
ClaimControl,
/// 客户端心跳,保持连接活跃。
Ping,
}
/// 服务端推送给客户端的消息 (分别适配 PC 和 移动端)
/// 移动端语义化屏显数据(已解耦、去除 ANSI 序列)。
#[derive(Debug, Clone, Serialize)]
pub struct MobileSnapshot {
/// 光标列(相对视口)。
pub cursor_x: usize,
/// 光标行(相对视口)。
pub cursor_y: usize,
/// 网格列数。
pub cols: usize,
/// 网格行数。
pub rows: usize,
/// 当前回滚显示偏移。
pub display_offset: usize,
/// 滚动历史中的总行数。
pub scrollback_lines: usize,
/// 视口内的逐行文本(已按词、去尾空白)。
pub lines: Vec<String>,
}
/// 服务端推送给客户端的消息JSON 文本帧)。
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage {
/// 原生 ANSI 字节流 (供 PC 端 xterm/flutter_alacritty 渲染)
RawData { data: String },
/// 移动端专属:解耦后的语义化 JSON 屏显数据
MobileSnapshot {
cursor_x: usize,
cursor_y: usize,
lines: Vec<String>,
/// 会话已就绪:携带会话 ID 与初始尺寸。
Ready {
id: String,
cols: u16,
rows: u16,
},
}
/// 移动端专属:解耦后的语义化屏显快照。
MobileSnapshot {
data: MobileSnapshot,
},
/// 控制权授予结果。
ControlResponse {
granted: bool,
holder: Option<String>,
},
/// 服务端错误。
Error {
message: String,
},
/// 会话已结束PTY 关闭)。
SessionClosed,
}
impl ServerMessage {
/// 序列化为 JSON 文本。
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
}
}