feat: add Flutter desktop client for Rtty

This commit is contained in:
2026-08-01 17:40:52 +08:00
parent f25506ec5b
commit 93ee801f27
31 changed files with 2679 additions and 0 deletions

View File

@@ -0,0 +1,319 @@
import 'package:flutter/material.dart';
import 'rtty_client.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,167 @@
import 'dart:async';
import 'dart:convert';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:xterm/xterm.dart';
/// 连接状态。
enum ConnState { disconnected, connecting, connected, error }
/// WebSocket 终端客户端。
///
/// 负责与 Rtty 服务端建立连接:
/// - 消费服务端的 JSON 控制帧ready / snapshot / control / error / session_closed
/// - 把 PTY 输出的原始 ANSI 二进制帧交给 [Terminal] 渲染;
/// - 把终端产生的输出(用户键入 + 控制序列响应)以 `input` 消息发回服务端。
class RttyClient {
RttyClient(this.terminal) {
// xterm 需要把终端输出(键入、控制序列响应)回传给后端。
terminal.onOutput = _handleTerminalOutput;
}
final Terminal terminal;
WebSocketChannel? _channel;
StreamSubscription? _sub;
ConnState _state = ConnState.disconnected;
ConnState get state => _state;
String? _sessionId;
String? get sessionId => _sessionId;
String? _error;
String? get error => _error;
bool get isConnected => _state == ConnState.connected;
/// 状态变化回调。
void Function(ConnState state)? onStateChanged;
/// 就绪回调(收到 ready 帧,携带会话 ID 与初始尺寸)。
void Function(String id, int cols, int rows)? onReady;
/// 会话结束回调。
void Function()? onSessionClosed;
/// 原始 ANSI 数据回调(用于调试 / 集成测试)。
void Function(String data)? onRawData;
/// 连接到服务端。
Future<void> connect({
required String host,
required int port,
String? session,
}) async {
await disconnect();
_setState(ConnState.connecting);
_error = null;
final query = session != null && session.isNotEmpty
? '?session=${Uri.encodeQueryComponent(session)}'
: '';
final uri = Uri.parse('ws://$host:$port/ws$query');
try {
_channel = IOWebSocketChannel.connect(uri);
_sub = _channel!.stream.listen(
_handleIncoming,
onError: (Object e) {
_error = e.toString();
_setState(ConnState.error);
},
onDone: () {
if (_state == ConnState.connected) {
_setState(ConnState.disconnected);
}
},
);
_setState(ConnState.connected);
} catch (e) {
_error = e.toString();
_setState(ConnState.error);
}
}
/// 断开连接。
Future<void> disconnect() async {
await _sub?.cancel();
_sub = null;
await _channel?.sink.close();
_channel = null;
_sessionId = null;
if (_state != ConnState.disconnected) {
_setState(ConnState.disconnected);
}
}
void _handleIncoming(dynamic message) {
if (message is List<int>) {
// PC 端原始 ANSI 字节流。
final text = utf8.decode(message, allowMalformed: true);
onRawData?.call(text);
terminal.write(text);
} else if (message is String) {
_handleJsonFrame(message);
}
}
void _handleJsonFrame(String text) {
final Object? decoded;
try {
decoded = jsonDecode(text);
} catch (_) {
return;
}
if (decoded is! Map<String, dynamic>) return;
switch (decoded['type']) {
case 'ready':
final id = decoded['id'] as String? ?? '';
final cols = (decoded['cols'] as num?)?.toInt() ?? terminal.viewWidth;
final rows = (decoded['rows'] as num?)?.toInt() ?? terminal.viewHeight;
_sessionId = id;
_setState(ConnState.connected);
onReady?.call(id, cols, rows);
case 'mobile_snapshot':
// 桌面端使用原始 ANSI 渲染,忽略语义化快照。
break;
case 'control_response':
case 'error':
case 'session_closed':
onSessionClosed?.call();
_setState(ConnState.disconnected);
default:
break;
}
}
/// xterm 需要把终端输出回传给后端。
void _handleTerminalOutput(String data) {
if (!isConnected || _channel == null) return;
final frame = jsonEncode({'type': 'input', 'data': data});
_channel!.sink.add(frame);
}
/// 发送 resize 指令。
void sendResize(int cols, int rows) {
if (!isConnected || _channel == null) return;
final frame = jsonEncode({'type': 'resize', 'cols': cols, 'rows': rows});
_channel!.sink.add(frame);
}
/// 发送原始输入字节到服务端(写入 PTY
void sendInput(String data) {
if (!isConnected || _channel == null) return;
final frame = jsonEncode({'type': 'input', 'data': data});
_channel!.sink.add(frame);
}
void _setState(ConnState state) {
if (_state == state) return;
_state = state;
onStateChanged?.call(state);
}
}

View File

@@ -0,0 +1,366 @@
import 'package:flutter/material.dart';
import 'package:xterm/xterm.dart';
import 'connection_bar.dart';
import 'rtty_client.dart';
import 'theme.dart';
/// 主终端页面。
///
/// 组织连接控制条 + xterm 终端渲染区 + 底部状态栏。
/// 断开时展示终端提示页,连接后进入实时渲染。
class TerminalScreen extends StatefulWidget {
const TerminalScreen({super.key});
@override
State<TerminalScreen> createState() => _TerminalScreenState();
}
class _TerminalScreenState extends State<TerminalScreen> {
late final Terminal _terminal;
late final RttyClient _client;
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();
_terminal = Terminal(onOutput: (_) {});
_client = RttyClient(_terminal);
_client.onStateChanged = (s) => setState(() => _state = s);
_client.onReady = (id, cols, rows) {
setState(() {
_sessionId = id;
_cols = cols;
_rows = rows;
});
};
_client.onSessionClosed = () {
if (mounted) {
setState(() {
_state = ConnState.disconnected;
_sessionId = null;
});
}
};
}
@override
void dispose() {
_client.disconnect();
_host.dispose();
_port.dispose();
_session.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;
_terminal.write('\x1b[2J\x1b[H');
await _client.connect(host: host, port: port, session: session);
}
Future<void> _disconnect() => _client.disconnect();
@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() {
if (_state == ConnState.connected) {
return _TerminalViewport(
terminal: _terminal,
cols: _cols,
rows: _rows,
onResize: _handleResize,
);
}
return _IdlePane(state: _state);
}
void _handleResize(int cols, int rows) {
setState(() {
_cols = cols;
_rows = rows;
});
_client.sendResize(cols, rows);
}
}
/// 终端渲染视口。监听尺寸变化并同步给服务端。
class _TerminalViewport extends StatefulWidget {
const _TerminalViewport({
required this.terminal,
required this.cols,
required this.rows,
required this.onResize,
});
final Terminal terminal;
final int cols;
final int rows;
final void Function(int cols, int rows) onResize;
@override
State<_TerminalViewport> createState() => _TerminalViewportState();
}
class _TerminalViewportState extends State<_TerminalViewport> {
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// 估算每格宽高,把像素尺寸转成网格尺寸并触发 resize。
final style = RttyTheme.terminalStyle();
final fontSize = style.fontSize;
final colWidth = fontSize * 0.62;
final rowHeight = fontSize * style.height;
final cols = (constraints.maxWidth / colWidth).floor().clamp(20, 500);
final rows =
(constraints.maxHeight / rowHeight).floor().clamp(5, 300);
// 网格变化时通知服务端。
if (cols != widget.cols || rows != widget.rows) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.terminal.resize(cols, rows);
widget.onResize(cols, rows);
});
}
return Container(
color: RttyTheme.background,
padding: const EdgeInsets.all(16),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
decoration: BoxDecoration(
color: RttyTheme.background,
border: Border.all(color: RttyTheme.border),
borderRadius: BorderRadius.circular(4),
),
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: TerminalView(
widget.terminal,
theme: RttyTheme.terminal(),
textStyle: style,
autofocus: true,
mouseCursor: SystemMouseCursors.text,
),
),
),
);
},
);
}
}
/// 未连接时的提示面板。
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('右键粘贴', RttyTheme.textFaint),
Spacer(),
Text(
'FLUTTER × XTERM × ALCATTERM 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,82 @@
import 'package:flutter/material.dart';
import 'package:xterm/xterm.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,
),
),
);
}
/// xterm 终端主题。
static TerminalTheme terminal() => TerminalTheme(
cursor: primary,
selection: const Color(0xFF2A5566).withValues(alpha: 0.45),
searchHitBackground: accent.withValues(alpha: 0.25),
searchHitBackgroundCurrent: accent,
searchHitForeground: const Color(0xFF14100A),
foreground: const Color(0xFFCBD5DF),
background: const Color(0xFF0B0E11),
black: const Color(0xFF0B0E11),
white: const Color(0xFFDDE6EE),
red: const Color(0xFFE5534B),
green: const Color(0xFF3DDC97),
yellow: const Color(0xFFFFB454),
blue: const Color(0xFF5AB0FF),
magenta: const Color(0xFFD77BFF),
cyan: const Color(0xFF4ED6E0),
brightBlack: const Color(0xFF4A5866),
brightRed: const Color(0xFFFF6B6B),
brightGreen: const Color(0xFF6BFFC2),
brightYellow: const Color(0xFFFFCE7A),
brightBlue: const Color(0xFF82C4FF),
brightMagenta: const Color(0xFFE8A6FF),
brightCyan: const Color(0xFF82EDF4),
brightWhite: const Color(0xFFFFFDF8),
);
/// xterm 终端字体。
static TerminalStyle terminalStyle() => const TerminalStyle(
fontFamily: 'Consolas',
fontFamilyFallback: ['Cascadia Mono', 'JetBrains Mono', 'Menlo'],
fontSize: 14,
height: 1.15,
);
}