feat: 移动端自动重连、颜色渲染与 ENTER 键
- 移动端: 断线自动重连(指数退避, 重连后服务端下发初始快照自动恢复屏幕) - 服务端: 语义快照新增 segments 着色分段(命名色/256色/RGB), 移动端按段渲染颜色 - 移动端: 终端按 ANSI 16 色调色板 + RGB 渲染前景/背景/粗体/斜体/下划线 - 移动端: Keybar 新增 ENTER 按钮 - 验证: 手机实测彩色输出上屏; claude -p 经同步终端运行并返回 PONG
This commit is contained in:
@@ -42,6 +42,7 @@ class _TerminalPageState extends State<TerminalPage> {
|
||||
String? _sessionId;
|
||||
bool _resumed = false;
|
||||
List<String> _lines = const [];
|
||||
List<List<Segment>> _segments = const [];
|
||||
int _cursorX = 0;
|
||||
int _cursorY = 0;
|
||||
|
||||
@@ -59,6 +60,7 @@ class _TerminalPageState extends State<TerminalPage> {
|
||||
('↓', '\x1b[B'),
|
||||
('←', '\x1b[D'),
|
||||
('→', '\x1b[C'),
|
||||
('ENTER', '\r'),
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -106,6 +108,7 @@ class _TerminalPageState extends State<TerminalPage> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_lines = snap.lines;
|
||||
_segments = snap.segments;
|
||||
_cursorX = snap.cursorX;
|
||||
_cursorY = snap.cursorY;
|
||||
});
|
||||
@@ -343,39 +346,102 @@ class _TerminalPageState extends State<TerminalPage> {
|
||||
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)),
|
||||
],
|
||||
children: _lineSpans(index),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 按着色片段生成文本 spans;光标行额外叠加高亮光标块。
|
||||
List<TextSpan> _lineSpans(int index) {
|
||||
final segs = (index < _segments.length && _segments[index].isNotEmpty)
|
||||
? _segments[index]
|
||||
: <Segment>[Segment(text: index < _lines.length ? _lines[index] : '')];
|
||||
|
||||
final spans = <TextSpan>[];
|
||||
var offset = 0;
|
||||
var cursorPlaced = false;
|
||||
for (final seg in segs) {
|
||||
if (seg.text.isEmpty) continue;
|
||||
final textLen = seg.text.length;
|
||||
|
||||
if (!cursorPlaced &&
|
||||
index == _cursorY &&
|
||||
_cursorX >= offset &&
|
||||
_cursorX <= offset + textLen) {
|
||||
final cut = _cursorX - offset;
|
||||
if (cut > 0) {
|
||||
spans.add(TextSpan(text: seg.text.substring(0, cut), style: _segStyle(seg)));
|
||||
}
|
||||
// 光标块:覆盖该列字符(行尾则补一个空格块)。
|
||||
final cursorChar = cut < textLen ? seg.text[cut] : ' ';
|
||||
spans.add(TextSpan(
|
||||
text: cursorChar,
|
||||
style: const TextStyle(
|
||||
color: RttyMobileTheme.background,
|
||||
backgroundColor: RttyMobileTheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
));
|
||||
if (cut + 1 < textLen) {
|
||||
spans.add(TextSpan(text: seg.text.substring(cut + 1), style: _segStyle(seg)));
|
||||
}
|
||||
cursorPlaced = true;
|
||||
} else {
|
||||
spans.add(TextSpan(text: seg.text, style: _segStyle(seg)));
|
||||
}
|
||||
offset += textLen;
|
||||
}
|
||||
|
||||
// 光标位于行文本之外(例如整行被去尾空白)时,行尾追加光标块。
|
||||
if (!cursorPlaced &&
|
||||
index == _cursorY &&
|
||||
_cursorX >= (index < _lines.length ? _lines[index].length : 0)) {
|
||||
spans.add(const TextSpan(
|
||||
text: ' ',
|
||||
style: TextStyle(backgroundColor: RttyMobileTheme.primary),
|
||||
));
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
TextStyle _segStyle(Segment seg) {
|
||||
return TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
height: 1.25,
|
||||
color: _colorFromCode(seg.fg) ?? RttyMobileTheme.text,
|
||||
backgroundColor: _colorFromCode(seg.bg),
|
||||
fontWeight: seg.bold ? FontWeight.bold : null,
|
||||
fontStyle: seg.italic ? FontStyle.italic : null,
|
||||
decoration: seg.underline ? TextDecoration.underline : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 把服务端的颜色编码(`null` / `n:k` / `r,g,b`)解析为 Flutter 颜色。
|
||||
Color? _colorFromCode(String? code) {
|
||||
if (code == null) return null;
|
||||
if (code.startsWith('n:')) {
|
||||
final i = int.tryParse(code.substring(2)) ?? 0;
|
||||
return _ansiPalette[i.clamp(0, 15)];
|
||||
}
|
||||
final parts = code.split(',');
|
||||
if (parts.length == 3) {
|
||||
final r = int.tryParse(parts[0]);
|
||||
final g = int.tryParse(parts[1]);
|
||||
final b = int.tryParse(parts[2]);
|
||||
if (r != null && g != null && b != null) {
|
||||
return Color.fromARGB(255, r, g, b);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget _buildKeybar(bool connected) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
|
||||
@@ -422,6 +488,26 @@ class _TerminalPageState extends State<TerminalPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 标准 ANSI 16 色调色板(移动端渲染命名色用)。
|
||||
const List<Color> _ansiPalette = <Color>[
|
||||
Color(0xFF1E1E1E), // black
|
||||
Color(0xFFCD3131), // red
|
||||
Color(0xFF0DBC79), // green
|
||||
Color(0xFFE5E510), // yellow
|
||||
Color(0xFF2472C8), // blue
|
||||
Color(0xFFBC3FBC), // magenta
|
||||
Color(0xFF11A8CD), // cyan
|
||||
Color(0xFFE5E5E5), // white
|
||||
Color(0xFF666666), // bright black
|
||||
Color(0xFFF14C4C), // bright red
|
||||
Color(0xFF23D18B), // bright green
|
||||
Color(0xFFF5F543), // bright yellow
|
||||
Color(0xFF3B8EEA), // bright blue
|
||||
Color(0xFFD670D6), // bright magenta
|
||||
Color(0xFF29B8DB), // bright cyan
|
||||
Color(0xFFFFFFFF), // bright white
|
||||
];
|
||||
|
||||
class _KeyButton extends StatelessWidget {
|
||||
const _KeyButton({required this.label, this.onTap});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
@@ -7,6 +8,40 @@ import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
/// 连接状态。
|
||||
enum ConnState { disconnected, connecting, connected, error }
|
||||
|
||||
/// 一行中的一段同风格文本(对应服务端 `SnapshotSegment`)。
|
||||
class Segment {
|
||||
const Segment({
|
||||
required this.text,
|
||||
this.fg,
|
||||
this.bg,
|
||||
this.bold = false,
|
||||
this.italic = false,
|
||||
this.underline = false,
|
||||
});
|
||||
|
||||
final String text;
|
||||
|
||||
/// 前景色编码:`null` 默认色;`n:5` 命名色索引;`r,g,b` 具体 RGB。
|
||||
final String? fg;
|
||||
|
||||
/// 背景色编码,格式同 [fg]。
|
||||
final String? bg;
|
||||
final bool bold;
|
||||
final bool italic;
|
||||
final bool underline;
|
||||
|
||||
factory Segment.fromJson(Map<String, dynamic> json) {
|
||||
return Segment(
|
||||
text: json['text'] as String? ?? '',
|
||||
fg: json['fg'] as String?,
|
||||
bg: json['bg'] as String?,
|
||||
bold: json['bold'] == true,
|
||||
italic: json['italic'] == true,
|
||||
underline: json['underline'] == true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 语义化屏显快照(对应服务端 `MobileSnapshot`)。
|
||||
class Snapshot {
|
||||
const Snapshot({
|
||||
@@ -17,6 +52,7 @@ class Snapshot {
|
||||
required this.displayOffset,
|
||||
required this.scrollbackLines,
|
||||
required this.lines,
|
||||
required this.segments,
|
||||
});
|
||||
|
||||
final int cursorX;
|
||||
@@ -26,8 +62,32 @@ class Snapshot {
|
||||
final int displayOffset;
|
||||
final int scrollbackLines;
|
||||
final List<String> lines;
|
||||
final List<List<Segment>> segments;
|
||||
|
||||
factory Snapshot.fromJson(Map<String, dynamic> json) {
|
||||
final lines = ((json['lines'] as List?) ?? const [])
|
||||
.map((l) => l.toString())
|
||||
.toList();
|
||||
|
||||
// 优先解析着色分段;旧服务端无 segments 时退化为纯文本行。
|
||||
final rawSegs = json['segments'] as List?;
|
||||
final segments = <List<Segment>>[];
|
||||
if (rawSegs != null) {
|
||||
for (final rawLine in rawSegs) {
|
||||
final line = <Segment>[];
|
||||
if (rawLine is List) {
|
||||
for (final s in rawLine) {
|
||||
if (s is Map<String, dynamic>) {
|
||||
line.add(Segment.fromJson(s));
|
||||
}
|
||||
}
|
||||
}
|
||||
segments.add(line);
|
||||
}
|
||||
} else {
|
||||
segments.addAll(lines.map((l) => [Segment(text: l)]));
|
||||
}
|
||||
|
||||
return Snapshot(
|
||||
cursorX: (json['cursor_x'] as num?)?.toInt() ?? 0,
|
||||
cursorY: (json['cursor_y'] as num?)?.toInt() ?? 0,
|
||||
@@ -35,17 +95,34 @@ class Snapshot {
|
||||
rows: (json['rows'] as num?)?.toInt() ?? 0,
|
||||
displayOffset: (json['display_offset'] as num?)?.toInt() ?? 0,
|
||||
scrollbackLines: (json['scrollback_lines'] as num?)?.toInt() ?? 0,
|
||||
lines: ((json['lines'] as List?) ?? const [])
|
||||
.map((l) => l.toString())
|
||||
.toList(),
|
||||
lines: lines,
|
||||
segments: segments,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次连接的参数(断线重连时复用)。
|
||||
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 服务端,消费 `mobile_snapshot` 语义化快照(已去 ANSI、按行切分),
|
||||
/// 并把用户输入 / resize / 控制权指令发回服务端。
|
||||
/// 连接 Rtty 服务端,消费 `mobile_snapshot` 语义化快照(已去 ANSI、按行切分、
|
||||
/// 带着色片段),并把用户输入 / resize / 控制权指令发回服务端。
|
||||
///
|
||||
/// 额外能力:断线自动重连(指数退避),重连成功后服务端会下发初始快照,
|
||||
/// 屏幕状态自动恢复。
|
||||
class RttyMobileClient {
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _sub;
|
||||
@@ -67,6 +144,15 @@ class RttyMobileClient {
|
||||
|
||||
Timer? _pingTimer;
|
||||
|
||||
/// 用户主动断开:不再自动重连。
|
||||
bool _manualClose = true;
|
||||
/// 不可恢复错误(鉴权失败 / 会话结束):不再自动重连。
|
||||
bool _fatal = false;
|
||||
|
||||
int _reconnectAttempts = 0;
|
||||
Timer? _reconnectTimer;
|
||||
_ConnectParams? _params;
|
||||
|
||||
/// 状态变化回调。
|
||||
void Function(ConnState state)? onStateChanged;
|
||||
|
||||
@@ -86,43 +172,84 @@ class RttyMobileClient {
|
||||
String? token,
|
||||
}) async {
|
||||
await disconnect();
|
||||
|
||||
_manualClose = false;
|
||||
_fatal = false;
|
||||
_reconnectAttempts = 0;
|
||||
_params = _ConnectParams(host: host, port: port, session: session, token: token);
|
||||
_setState(ConnState.connecting);
|
||||
_error = null;
|
||||
_openChannel();
|
||||
}
|
||||
|
||||
void _openChannel() {
|
||||
final p = _params;
|
||||
if (p == null || _manualClose || _fatal) return;
|
||||
|
||||
// 关键:必须声明 client=mobile,服务端才会推送语义化快照。
|
||||
final query = StringBuffer('?client=mobile');
|
||||
if (token != null && token.isNotEmpty) {
|
||||
query.write('&token=${Uri.encodeQueryComponent(token)}');
|
||||
if (p.token != null && p.token!.isNotEmpty) {
|
||||
query.write('&token=${Uri.encodeQueryComponent(p.token!)}');
|
||||
}
|
||||
if (session != null && session.isNotEmpty) {
|
||||
query.write('&session=${Uri.encodeQueryComponent(session)}');
|
||||
if (p.session != null && p.session!.isNotEmpty) {
|
||||
query.write('&session=${Uri.encodeQueryComponent(p.session!)}');
|
||||
}
|
||||
final uri = Uri.parse('ws://$host:$port/ws$query');
|
||||
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) {
|
||||
if (_manualClose || _fatal) return;
|
||||
_error = e.toString();
|
||||
_setState(ConnState.error);
|
||||
_scheduleReconnect();
|
||||
},
|
||||
onDone: () {
|
||||
if (_state == ConnState.connected) {
|
||||
_setState(ConnState.disconnected);
|
||||
}
|
||||
},
|
||||
onDone: _handleDone,
|
||||
);
|
||||
_setState(ConnState.connected);
|
||||
_startPing();
|
||||
} catch (e) {
|
||||
if (_manualClose || _fatal) return;
|
||||
_error = e.toString();
|
||||
_setState(ConnState.error);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDone() {
|
||||
_stopPing();
|
||||
if (_manualClose) {
|
||||
if (_state == ConnState.connected) _setState(ConnState.disconnected);
|
||||
return;
|
||||
}
|
||||
if (_fatal) {
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_manualClose = true;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
_stopPing();
|
||||
await _sub?.cancel();
|
||||
_sub = null;
|
||||
@@ -156,6 +283,7 @@ class RttyMobileClient {
|
||||
_resumed = decoded['resumed'] == true;
|
||||
final cols = (decoded['cols'] as num?)?.toInt() ?? 0;
|
||||
final rows = (decoded['rows'] as num?)?.toInt() ?? 0;
|
||||
_reconnectAttempts = 0; // 重连成功,重置退避。
|
||||
_setState(ConnState.connected);
|
||||
onReady?.call(_sessionId ?? '', cols, rows, _resumed);
|
||||
break;
|
||||
@@ -167,9 +295,14 @@ class RttyMobileClient {
|
||||
break;
|
||||
case 'control_response':
|
||||
case 'pong':
|
||||
break;
|
||||
case 'error':
|
||||
_error = decoded['message'] as String? ?? 'server error';
|
||||
_fatal = true; // 鉴权失败等不可恢复错误,不再重连。
|
||||
_setState(ConnState.error);
|
||||
break;
|
||||
case 'session_closed':
|
||||
_fatal = true; // 会话已结束,不再重连。
|
||||
onSessionClosed?.call();
|
||||
_setState(ConnState.disconnected);
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user