- 服务端: RTTY_TOKEN 最小鉴权; Ready.resumed 会话恢复语义; 会话按 ID 原子创建 - 服务端: 桌面端连接/重连时全屏 ANSI 重绘; 惰性语义快照(仅移动端订阅时生成); Ping/Pong 心跳 - 桌面端: 输入改二进制帧无损透传; 指数退避自动重连; token 输入框; resumed 状态展示 - 移动端: 修复 client=mobile 参数(此前永远收不到快照); 新增最小验证 App(连接/快照显示/输入/Keybar); 支持 token; Android 网络权限 - 测试: 修正 e2e onReady 签名; 新增移动端 widget 测试
453 lines
13 KiB
Dart
453 lines
13 KiB
Dart
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),
|
||
),
|
||
);
|
||
}
|
||
}
|