feat: 终端同步加固与移动端最小验证 App

- 服务端: RTTY_TOKEN 最小鉴权; Ready.resumed 会话恢复语义; 会话按 ID 原子创建
- 服务端: 桌面端连接/重连时全屏 ANSI 重绘; 惰性语义快照(仅移动端订阅时生成); Ping/Pong 心跳
- 桌面端: 输入改二进制帧无损透传; 指数退避自动重连; token 输入框; resumed 状态展示
- 移动端: 修复 client=mobile 参数(此前永远收不到快照); 新增最小验证 App(连接/快照显示/输入/Keybar); 支持 token; Android 网络权限
- 测试: 修正 e2e onReady 签名; 新增移动端 widget 测试
This commit is contained in:
2026-08-02 18:49:39 +08:00
parent 339fbe357f
commit 86d399c706
78 changed files with 3158 additions and 101 deletions

452
mobile/lib/main.dart Normal file
View File

@@ -0,0 +1,452 @@
import 'package:flutter/material.dart';
import 'src/rtty_mobile_client.dart';
import 'src/theme.dart';
void main() {
runApp(const RttyMobileApp());
}
class RttyMobileApp extends StatelessWidget {
const RttyMobileApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Rtty Mobile',
debugShowCheckedModeBanner: false,
theme: RttyMobileTheme.app(),
home: const TerminalPage(),
);
}
}
/// 最小验证页:连接 Rtty 服务端,消费语义化快照实时显示终端内容,
/// 并支持输入命令 + 常用按键ESC/TAB/Ctrl/方向键)做端到端同步验证。
class TerminalPage extends StatefulWidget {
const TerminalPage({super.key});
@override
State<TerminalPage> createState() => _TerminalPageState();
}
class _TerminalPageState extends State<TerminalPage> {
final RttyMobileClient _client = RttyMobileClient();
final TextEditingController _serverCtrl = TextEditingController();
final TextEditingController _tokenCtrl = TextEditingController();
final TextEditingController _sessionCtrl = TextEditingController();
final TextEditingController _inputCtrl = TextEditingController();
final ScrollController _scrollCtrl = ScrollController();
ConnState _state = ConnState.disconnected;
String? _sessionId;
bool _resumed = false;
List<String> _lines = const [];
int _cursorX = 0;
int _cursorY = 0;
/// 终端行高(逻辑像素),与 [_buildLine] 的字号/行高保持一致,
/// 用于“让光标行保持可见”的滚动计算。
static const double _lineHeight = 17.0;
/// 常用按键:标签 -> 发送到 PTY 的字节序列。
static const List<(String, String)> _quickKeys = [
('ESC', '\x1b'),
('TAB', '\t'),
('CTRL+C', '\x03'),
('CTRL+L', '\x0c'),
('', '\x1b[A'),
('', '\x1b[B'),
('', '\x1b[D'),
('', '\x1b[C'),
];
@override
void initState() {
super.initState();
_client.onStateChanged = _onStateChanged;
_client.onReady = (id, cols, rows, resumed) {
if (!mounted) return;
setState(() {
_sessionId = id;
_resumed = resumed;
_state = ConnState.connected;
});
};
_client.onSnapshot = _onSnapshot;
_client.onSessionClosed = () {
if (!mounted) return;
setState(() => _state = ConnState.disconnected);
};
}
@override
void dispose() {
_client.disconnect();
_serverCtrl.dispose();
_tokenCtrl.dispose();
_sessionCtrl.dispose();
_inputCtrl.dispose();
_scrollCtrl.dispose();
super.dispose();
}
void _onStateChanged(ConnState s) {
if (!mounted) return;
setState(() {
_state = s;
if (s == ConnState.disconnected || s == ConnState.error) {
_sessionId = null;
_resumed = false;
}
});
}
void _onSnapshot(Snapshot snap) {
if (!mounted) return;
setState(() {
_lines = snap.lines;
_cursorX = snap.cursorX;
_cursorY = snap.cursorY;
});
// 终端语义:让光标行始终可见(尽量靠近视口底部),而不是盲目滚到
// 列表末尾——网格下半部分通常是空行,滚到底会把内容顶出屏幕。
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scrollCtrl.hasClients) return;
final viewport = _scrollCtrl.position.viewportDimension;
final max = _scrollCtrl.position.maxScrollExtent;
final target = ((_cursorY + 1) * _lineHeight) - viewport;
_scrollCtrl.jumpTo(target.clamp(0.0, max));
});
}
(String, int) _parseServer() {
final raw = _serverCtrl.text.trim();
if (raw.isEmpty) return ('', 8080);
final idx = raw.lastIndexOf(':');
if (idx <= 0) return (raw, 8080);
final port = int.tryParse(raw.substring(idx + 1)) ?? 8080;
return (raw.substring(0, idx), port);
}
Future<void> _connect() async {
final (host, port) = _parseServer();
if (host.isEmpty) {
_showSnack('请输入服务端地址,如 192.168.1.100:8080');
return;
}
setState(() => _state = ConnState.connecting);
await _client.connect(
host: host,
port: port,
session:
_sessionCtrl.text.trim().isEmpty ? null : _sessionCtrl.text.trim(),
token: _tokenCtrl.text.trim().isEmpty ? null : _tokenCtrl.text.trim(),
);
}
Future<void> _disconnect() async {
await _client.disconnect();
}
void _send(String data) {
if (!_client.isConnected) return;
_client.sendInput(data);
}
void _sendLine() {
if (_inputCtrl.text.isEmpty) return;
_send('${_inputCtrl.text}\r');
_inputCtrl.clear();
}
void _showSnack(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(msg), duration: const Duration(seconds: 2)));
}
@override
Widget build(BuildContext context) {
final connected = _client.isConnected;
return Scaffold(
body: SafeArea(
child: Column(
children: [
_buildConnectionPanel(connected),
const Divider(height: 1, color: RttyMobileTheme.border),
Expanded(child: _buildTerminal()),
_buildKeybar(connected),
_buildInputRow(connected),
],
),
),
);
}
Widget _buildConnectionPanel(bool connected) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: Column(
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _serverCtrl,
enabled: !connected,
style: const TextStyle(fontFamily: 'monospace', fontSize: 14),
decoration: const InputDecoration(
labelText: 'SERVER',
hintText: '192.168.1.100:8080',
isDense: true,
),
),
),
const SizedBox(width: 8),
SizedBox(
width: 110,
child: TextField(
controller: _tokenCtrl,
enabled: !connected,
obscureText: true,
style: const TextStyle(fontFamily: 'monospace', fontSize: 14),
decoration: const InputDecoration(
labelText: 'TOKEN',
hintText: '可选',
isDense: true,
),
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: connected ? _disconnect : _connect,
style: FilledButton.styleFrom(
backgroundColor: connected
? RttyMobileTheme.danger
: RttyMobileTheme.primary,
foregroundColor: connected
? Colors.white
: RttyMobileTheme.background,
),
child: Text(connected ? '断开' : '连接'),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: _sessionCtrl,
enabled: !connected,
style: const TextStyle(fontFamily: 'monospace', fontSize: 14),
decoration: const InputDecoration(
labelText: 'SESSION',
hintText: '留空自动新建',
isDense: true,
),
),
),
const SizedBox(width: 8),
Expanded(child: _buildStatus()),
],
),
],
),
);
}
Widget _buildStatus() {
final stateText = switch (_state) {
ConnState.disconnected => '未连接',
ConnState.connecting => '连接中…',
ConnState.connected => '已连接',
ConnState.error => '连接失败',
};
final color = switch (_state) {
ConnState.connected => RttyMobileTheme.primary,
ConnState.connecting => RttyMobileTheme.accent,
ConnState.error => RttyMobileTheme.danger,
ConnState.disconnected => RttyMobileTheme.textFaint,
};
final session = (_sessionId == null || _sessionId!.isEmpty)
? ''
: ' · ${_resumed ? '恢复' : '新建'}:$_sessionId';
final err = (_state == ConnState.error && _client.error != null)
? '\n${_client.error}'
: '';
return Text(
'$stateText$session$err',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: color,
fontSize: 12,
fontFamily: 'monospace',
),
);
}
Widget _buildTerminal() {
if (!_client.isConnected) {
final (title, sub) = switch (_state) {
ConnState.error => (
'连接失败',
_client.error ?? '请检查地址、令牌与服务端是否启动',
),
ConnState.connecting => ('连接中', '正在协商 WebSocket 会话…'),
_ => ('未连接', '输入服务端地址后点击「连接」'),
};
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.terminal,
size: 48,
color: _state == ConnState.error
? RttyMobileTheme.danger
: RttyMobileTheme.textFaint,
),
const SizedBox(height: 12),
Text(title,
style: TextStyle(
color: RttyMobileTheme.textDim,
fontSize: 16,
fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Text(sub,
textAlign: TextAlign.center,
style: const TextStyle(
color: RttyMobileTheme.textFaint, fontSize: 12)),
],
),
);
}
return Container(
color: RttyMobileTheme.background,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: ListView.builder(
controller: _scrollCtrl,
itemCount: _lines.length,
itemBuilder: (context, i) => _buildLine(i),
),
);
}
Widget _buildLine(int index) {
final line = index < _lines.length ? _lines[index] : '';
final mono = const TextStyle(
fontFamily: 'monospace',
fontSize: 13,
height: 1.25,
color: RttyMobileTheme.text,
);
if (index != _cursorY) {
return SizedBox(height: _lineHeight, child: Text(line, style: mono));
}
// 光标行:用高亮色块标出光标位置。
final x = _cursorX.clamp(0, line.length);
return SizedBox(
height: _lineHeight,
child: Text.rich(
TextSpan(
style: mono,
children: [
TextSpan(text: line.substring(0, x)),
if (x < line.length)
TextSpan(
text: line[x],
style: const TextStyle(
color: RttyMobileTheme.background,
backgroundColor: RttyMobileTheme.primary,
),
)
else
const TextSpan(
text: ' ',
style: TextStyle(backgroundColor: RttyMobileTheme.primary),
),
if (x + 1 < line.length) TextSpan(text: line.substring(x + 1)),
],
),
),
);
}
Widget _buildKeybar(bool connected) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final (label, seq) in _quickKeys)
_KeyButton(
label: label,
onTap: connected ? () => _send(seq) : null,
),
],
),
);
}
Widget _buildInputRow(bool connected) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Row(
children: [
Expanded(
child: TextField(
controller: _inputCtrl,
enabled: connected,
style: const TextStyle(fontFamily: 'monospace', fontSize: 14),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendLine(),
decoration: const InputDecoration(
hintText: '输入命令,回车发送',
isDense: true,
),
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: connected ? _sendLine : null,
child: const Text('发送'),
),
],
),
);
}
}
class _KeyButton extends StatelessWidget {
const _KeyButton({required this.label, this.onTap});
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 30,
child: OutlinedButton(
onPressed: onTap,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 10),
side: BorderSide(
color: onTap == null
? RttyMobileTheme.border
: RttyMobileTheme.primaryDim,
),
foregroundColor:
onTap == null ? RttyMobileTheme.textFaint : RttyMobileTheme.primary,
textStyle: const TextStyle(fontSize: 11, fontFamily: 'monospace'),
),
child: Text(label),
),
);
}
}