Files
Rtty/mobile/lib/main.dart
CNWei 86d399c706 feat: 终端同步加固与移动端最小验证 App
- 服务端: RTTY_TOKEN 最小鉴权; Ready.resumed 会话恢复语义; 会话按 ID 原子创建
- 服务端: 桌面端连接/重连时全屏 ANSI 重绘; 惰性语义快照(仅移动端订阅时生成); Ping/Pong 心跳
- 桌面端: 输入改二进制帧无损透传; 指数退避自动重连; token 输入框; resumed 状态展示
- 移动端: 修复 client=mobile 参数(此前永远收不到快照); 新增最小验证 App(连接/快照显示/输入/Keybar); 支持 token; Android 网络权限
- 测试: 修正 e2e onReady 签名; 新增移动端 widget 测试
2026-08-02 18:49:39 +08:00

453 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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