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

View File

@@ -13,8 +13,10 @@ class ConnectionBar extends StatelessWidget {
required this.state,
required this.hostController,
required this.portController,
required this.tokenController,
required this.sessionController,
required this.sessionId,
required this.resumed,
required this.terminalSize,
required this.onConnect,
required this.onDisconnect,
@@ -23,8 +25,10 @@ class ConnectionBar extends StatelessWidget {
final ConnState state;
final TextEditingController hostController;
final TextEditingController portController;
final TextEditingController tokenController;
final TextEditingController sessionController;
final String? sessionId;
final bool resumed;
final String terminalSize;
final VoidCallback onConnect;
final VoidCallback onDisconnect;
@@ -41,49 +45,61 @@ class ConnectionBar extends StatelessWidget {
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) ...[
// 窗口过窄时横向滚动,避免字段溢出。
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_StatusLight(state: state),
const SizedBox(width: 12),
_ConnectionLabel(state: state),
const SizedBox(width: 16),
_Divider(),
const SizedBox(width: 16),
_EditableField(
label: 'SESSION',
controller: sessionController,
enabled: true,
width: 120,
hint: 'auto',
label: 'HOST',
controller: hostController,
enabled: !_connected,
width: 130,
),
const SizedBox(width: 8),
_EditableField(
label: 'PORT',
controller: portController,
enabled: !_connected,
width: 64,
numeric: true,
),
const SizedBox(width: 8),
_EditableField(
label: 'TOKEN',
controller: tokenController,
enabled: !_connected,
width: 110,
hint: 'optional',
),
const SizedBox(width: 8),
if (!_connected) ...[
_EditableField(
label: 'SESSION',
controller: sessionController,
enabled: true,
width: 110,
hint: 'auto',
),
] else ...[
_SessionBadge(sessionId: sessionId, resumed: resumed),
],
const SizedBox(width: 24),
_TerminalSizeBadge(text: terminalSize),
const SizedBox(width: 12),
_ConnectButton(
state: state,
onConnect: onConnect,
onDisconnect: onDisconnect,
),
] else ...[
_SessionBadge(sessionId: sessionId),
],
const Spacer(),
_TerminalSizeBadge(text: terminalSize),
const SizedBox(width: 12),
_ConnectButton(
state: state,
onConnect: onConnect,
onDisconnect: onDisconnect,
),
],
),
),
);
}
@@ -222,9 +238,10 @@ class _EditableField extends StatelessWidget {
}
class _SessionBadge extends StatelessWidget {
const _SessionBadge({required this.sessionId});
const _SessionBadge({required this.sessionId, required this.resumed});
final String? sessionId;
final bool resumed;
@override
Widget build(BuildContext context) {
@@ -236,7 +253,7 @@ class _SessionBadge extends StatelessWidget {
border: Border.all(color: RttyTheme.border),
),
child: Text(
'SID ${sessionId ?? ''}',
'SID ${sessionId ?? ''}${resumed ? ' · RESUMED' : ' · NEW'}',
style: const TextStyle(
color: RttyTheme.textDim,
fontSize: 11,

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter_alacritty/flutter_alacritty.dart';
@@ -9,6 +10,21 @@ import 'package:web_socket_channel/web_socket_channel.dart';
/// 连接状态。
enum ConnState { disconnected, connecting, connected, error }
/// 一次连接的参数(断线重连时复用)。
class _ConnectParams {
const _ConnectParams({
required this.host,
required this.port,
this.session,
this.token,
});
final String host;
final int port;
final String? session;
final String? token;
}
/// 将 Rtty Rust 服务端桥接为 flutter_alacritty 的 [PtyBackend]。
///
/// 这正是该库为远程WebSocket/SSH数据源预留的扩展点
@@ -16,6 +32,10 @@ enum ConnState { disconnected, connecting, connected, error }
/// - [write]:把引擎产生的输入字节发回服务端写入远端 PTY
/// - [resize]:同步终端尺寸到服务端;
/// - [exitCode]:服务端会话结束时完成。
///
/// 额外能力:
/// - 断线自动重连(指数退避),重连成功后服务端会重放全屏,画面无缝恢复;
/// - 每 30s 发送一次心跳,保持连接活跃并探测服务端存活。
class RttyPtyBackend implements PtyBackend {
RttyPtyBackend();
@@ -32,11 +52,25 @@ class RttyPtyBackend implements PtyBackend {
String? _sessionId;
String? get sessionId => _sessionId;
/// 是否恢复了已存在的会话false = 本次连接新建了会话)。
bool _resumed = false;
bool get resumed => _resumed;
String? _error;
String? get error => _error;
/// 就绪回调(收到 ready 帧,携带会话 ID 与初始尺寸)
void Function(String id, int cols, int rows)? onReady;
/// 用户主动断开/关闭:不再自动重连
bool _manualClose = true;
/// 不可恢复错误(鉴权失败 / 会话结束):不再自动重连。
bool _fatal = false;
int _reconnectAttempts = 0;
Timer? _reconnectTimer;
Timer? _pingTimer;
_ConnectParams? _params;
/// 就绪回调(收到 ready 帧,携带会话 ID、初始尺寸与恢复标记
void Function(String id, int cols, int rows, bool resumed)? onReady;
/// 状态变化回调。
void Function(ConnState state)? onStateChanged;
@@ -55,38 +89,83 @@ class RttyPtyBackend implements PtyBackend {
required String host,
required int port,
String? session,
String? token,
}) async {
await close();
_manualClose = false;
_fatal = false;
_reconnectAttempts = 0;
_params = _ConnectParams(host: host, port: port, session: session, token: token);
_setState(ConnState.connecting);
_error = null;
_openChannel();
}
final query = (session != null && session.isNotEmpty)
? '?session=${Uri.encodeQueryComponent(session)}&client=desktop'
: '?client=desktop';
final uri = Uri.parse('ws://$host:$port/ws$query');
void _openChannel() {
final p = _params;
if (p == null || _manualClose || _fatal) return;
final query = StringBuffer('?client=desktop');
if (p.token != null && p.token!.isNotEmpty) {
query.write('&token=${Uri.encodeQueryComponent(p.token!)}');
}
if (p.session != null && p.session!.isNotEmpty) {
query.write('&session=${Uri.encodeQueryComponent(p.session!)}');
}
final uri = Uri.parse('ws://${p.host}:${p.port}/ws$query');
try {
_channel = IOWebSocketChannel.connect(uri);
_sub = _channel!.stream.listen(
final channel = IOWebSocketChannel.connect(uri);
_channel = channel;
_sub = channel.stream.listen(
_handleIncoming,
onError: (Object e) {
_error = e.toString();
_setState(ConnState.error);
_scheduleReconnect();
},
onDone: () {
if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(0);
if (_state == ConnState.connected) _setState(ConnState.disconnected);
},
onDone: _handleDone,
);
_setState(ConnState.connected);
// 收到 ready 帧后才转 connected期间保持 connecting。
_startPing();
} catch (e) {
_error = e.toString();
if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(1);
_setState(ConnState.error);
_scheduleReconnect();
}
}
/// 连接被对端关闭(或底层错误)后的统一出口。
void _handleDone() {
_stopPing();
if (_manualClose) {
if (_state == ConnState.connected) _setState(ConnState.disconnected);
return;
}
if (_fatal) {
if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(1);
if (_state != ConnState.error) _setState(ConnState.error);
return;
}
// 网络断开:进入指数退避重连。
if (_state != ConnState.disconnected) _setState(ConnState.disconnected);
_scheduleReconnect();
}
/// 指数退避重连500ms → 1s → 2s → …,上限 10s收到 ready 后重置。
void _scheduleReconnect() {
if (_manualClose || _fatal) return;
_reconnectTimer?.cancel();
final backoffMs =
math.min(500 * (1 << math.min(_reconnectAttempts, 5)), 10000);
_reconnectAttempts++;
_reconnectTimer = Timer(Duration(milliseconds: backoffMs), () {
if (_manualClose || _fatal) return;
_setState(ConnState.connecting);
_openChannel();
});
}
void _handleIncoming(dynamic message) {
if (message is List<int>) {
// PC 端原始 ANSI 字节流 → 喂给引擎渲染。
@@ -108,15 +187,25 @@ class RttyPtyBackend implements PtyBackend {
switch (decoded['type']) {
case 'ready':
_sessionId = decoded['id'] as String? ?? '';
_resumed = decoded['resumed'] == true;
final cols = (decoded['cols'] as num?)?.toInt() ?? 80;
final rows = (decoded['rows'] as num?)?.toInt() ?? 24;
_reconnectAttempts = 0; // 重连成功,重置退避。
_setState(ConnState.connected);
onReady?.call(_sessionId ?? '', cols, rows);
onReady?.call(_sessionId ?? '', cols, rows, _resumed);
case 'mobile_snapshot':
// 桌面端使用原始 ANSI 渲染,忽略语义化快照。
break;
case 'pong':
// 心跳应答,无需处理。
break;
case 'error':
_error = decoded['message'] as String? ?? 'server error';
_fatal = true; // 鉴权失败等不可恢复错误,不再重连。
if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(1);
_setState(ConnState.error);
case 'session_closed':
_fatal = true; // 会话已结束,不再重连。
if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(0);
_setState(ConnState.disconnected);
default:
@@ -127,10 +216,8 @@ class RttyPtyBackend implements PtyBackend {
@override
void write(Uint8List data) {
if (_state != ConnState.connected || _channel == null) return;
_channel!.sink.add(jsonEncode({
'type': 'input',
'data': utf8.decode(data, allowMalformed: true),
}));
// 二进制帧原样透传:避免经 UTF-8 字符串中转时损坏任意字节序列。
_channel!.sink.add(data);
}
@override
@@ -145,15 +232,39 @@ class RttyPtyBackend implements PtyBackend {
close();
}
/// 关闭连接并释放资源。
/// 用户主动关闭连接并释放资源。
Future<void> close() async {
_manualClose = true;
_fatal = false;
_reconnectTimer?.cancel();
_reconnectTimer = null;
_stopPing();
await _teardownChannel();
_sessionId = null;
_resumed = false;
if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(0);
if (_state != ConnState.disconnected) _setState(ConnState.disconnected);
}
Future<void> _teardownChannel() async {
await _sub?.cancel();
_sub = null;
await _channel?.sink.close();
_channel = null;
_sessionId = null;
if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(0);
if (_state != ConnState.disconnected) _setState(ConnState.disconnected);
}
void _startPing() {
_stopPing();
_pingTimer = Timer.periodic(const Duration(seconds: 30), (_) {
if (_state == ConnState.connected && _channel != null) {
_channel!.sink.add(jsonEncode({'type': 'ping'}));
}
});
}
void _stopPing() {
_pingTimer?.cancel();
_pingTimer = null;
}
void _setState(ConnState state) {

View File

@@ -38,10 +38,12 @@ class _TerminalScreenState extends State<TerminalScreen> {
final _host = TextEditingController(text: '127.0.0.1');
final _port = TextEditingController(text: '8080');
final _token = TextEditingController();
final _session = TextEditingController();
ConnState _state = ConnState.disconnected;
String? _sessionId;
bool _resumed = false;
int _cols = 0;
int _rows = 0;
@@ -56,6 +58,7 @@ class _TerminalScreenState extends State<TerminalScreen> {
_tearDownSession();
_host.dispose();
_port.dispose();
_token.dispose();
_session.dispose();
_controller.dispose();
_focus.dispose();
@@ -65,6 +68,7 @@ class _TerminalScreenState extends State<TerminalScreen> {
Future<void> _connect() async {
final host = _host.text.trim();
final port = int.tryParse(_port.text.trim()) ?? 8080;
final token = _token.text.trim();
final session = _session.text.trim();
if (host.isEmpty) return;
@@ -91,16 +95,17 @@ class _TerminalScreenState extends State<TerminalScreen> {
_sessionId = null;
}
};
backend.onReady = (id, cols, rows) {
backend.onReady = (id, cols, rows, resumed) {
if (!mounted) return;
setState(() {
_sessionId = id;
_resumed = resumed;
_cols = cols;
_rows = rows;
});
};
await backend.connect(host: host, port: port, session: session);
await backend.connect(host: host, port: port, session: session, token: token);
}
void _disconnect() => _tearDownSession();
@@ -124,6 +129,7 @@ class _TerminalScreenState extends State<TerminalScreen> {
_engine?.dispose();
_engine = null;
_sessionId = null;
_resumed = false;
_state = ConnState.disconnected;
}
@@ -158,8 +164,10 @@ class _TerminalScreenState extends State<TerminalScreen> {
state: _state,
hostController: _host,
portController: _port,
tokenController: _token,
sessionController: _session,
sessionId: _sessionId,
resumed: _resumed,
terminalSize: sizeText,
onConnect: _connect,
onDisconnect: _disconnect,

View File

@@ -25,7 +25,8 @@ void main() {
String? sessionId;
final raw = StringBuffer();
backend.onStateChanged = (s) => states.add(s);
backend.onReady = (id, cols, rows) => sessionId = '$id:$cols:$rows';
backend.onReady = (id, cols, rows, resumed) =>
sessionId = '$id:$cols:$rows:$resumed';
backend.output.listen((Uint8List b) {
raw.write(String.fromCharCodes(b));
});