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:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user