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:
452
mobile/lib/main.dart
Normal file
452
mobile/lib/main.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user