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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
220
mobile/lib/src/rtty_mobile_client.dart
Normal file
220
mobile/lib/src/rtty_mobile_client.dart
Normal file
@@ -0,0 +1,220 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
/// 连接状态。
|
||||
enum ConnState { disconnected, connecting, connected, error }
|
||||
|
||||
/// 语义化屏显快照(对应服务端 `MobileSnapshot`)。
|
||||
class Snapshot {
|
||||
const Snapshot({
|
||||
required this.cursorX,
|
||||
required this.cursorY,
|
||||
required this.cols,
|
||||
required this.rows,
|
||||
required this.displayOffset,
|
||||
required this.scrollbackLines,
|
||||
required this.lines,
|
||||
});
|
||||
|
||||
final int cursorX;
|
||||
final int cursorY;
|
||||
final int cols;
|
||||
final int rows;
|
||||
final int displayOffset;
|
||||
final int scrollbackLines;
|
||||
final List<String> lines;
|
||||
|
||||
factory Snapshot.fromJson(Map<String, dynamic> json) {
|
||||
return Snapshot(
|
||||
cursorX: (json['cursor_x'] as num?)?.toInt() ?? 0,
|
||||
cursorY: (json['cursor_y'] as num?)?.toInt() ?? 0,
|
||||
cols: (json['cols'] as num?)?.toInt() ?? 0,
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 移动端语义化客户端。
|
||||
///
|
||||
/// 连接 Rtty 服务端,消费 `mobile_snapshot` 语义化快照(已去 ANSI、按行切分),
|
||||
/// 并把用户输入 / resize / 控制权指令发回服务端。
|
||||
class RttyMobileClient {
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _sub;
|
||||
|
||||
ConnState _state = ConnState.disconnected;
|
||||
ConnState get state => _state;
|
||||
|
||||
String? _sessionId;
|
||||
String? get sessionId => _sessionId;
|
||||
|
||||
/// 是否恢复了已存在的会话。
|
||||
bool _resumed = false;
|
||||
bool get resumed => _resumed;
|
||||
|
||||
String? _error;
|
||||
String? get error => _error;
|
||||
|
||||
bool get isConnected => _state == ConnState.connected;
|
||||
|
||||
Timer? _pingTimer;
|
||||
|
||||
/// 状态变化回调。
|
||||
void Function(ConnState state)? onStateChanged;
|
||||
|
||||
/// 就绪回调(会话 ID、初始尺寸、是否恢复既有会话)。
|
||||
void Function(String id, int cols, int rows, bool resumed)? onReady;
|
||||
|
||||
/// 快照回调(每次服务端推送语义化屏显)。
|
||||
void Function(Snapshot snapshot)? onSnapshot;
|
||||
|
||||
/// 会话结束回调。
|
||||
void Function()? onSessionClosed;
|
||||
|
||||
Future<void> connect({
|
||||
required String host,
|
||||
required int port,
|
||||
String? session,
|
||||
String? token,
|
||||
}) async {
|
||||
await disconnect();
|
||||
|
||||
_setState(ConnState.connecting);
|
||||
_error = null;
|
||||
|
||||
// 关键:必须声明 client=mobile,服务端才会推送语义化快照。
|
||||
final query = StringBuffer('?client=mobile');
|
||||
if (token != null && token.isNotEmpty) {
|
||||
query.write('&token=${Uri.encodeQueryComponent(token)}');
|
||||
}
|
||||
if (session != null && session.isNotEmpty) {
|
||||
query.write('&session=${Uri.encodeQueryComponent(session)}');
|
||||
}
|
||||
final uri = Uri.parse('ws://$host:$port/ws$query');
|
||||
|
||||
try {
|
||||
_channel = IOWebSocketChannel.connect(uri);
|
||||
_sub = _channel!.stream.listen(
|
||||
_handleIncoming,
|
||||
onError: (Object e) {
|
||||
_error = e.toString();
|
||||
_setState(ConnState.error);
|
||||
},
|
||||
onDone: () {
|
||||
if (_state == ConnState.connected) {
|
||||
_setState(ConnState.disconnected);
|
||||
}
|
||||
},
|
||||
);
|
||||
_setState(ConnState.connected);
|
||||
_startPing();
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
_setState(ConnState.error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_stopPing();
|
||||
await _sub?.cancel();
|
||||
_sub = null;
|
||||
await _channel?.sink.close();
|
||||
_channel = null;
|
||||
_sessionId = null;
|
||||
_resumed = false;
|
||||
if (_state != ConnState.disconnected) {
|
||||
_setState(ConnState.disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleIncoming(dynamic message) {
|
||||
if (message is String) {
|
||||
_handleJsonFrame(message);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleJsonFrame(String text) {
|
||||
final Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(text);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (decoded is! Map<String, dynamic>) return;
|
||||
|
||||
switch (decoded['type']) {
|
||||
case 'ready':
|
||||
_sessionId = decoded['id'] as String? ?? '';
|
||||
_resumed = decoded['resumed'] == true;
|
||||
final cols = (decoded['cols'] as num?)?.toInt() ?? 0;
|
||||
final rows = (decoded['rows'] as num?)?.toInt() ?? 0;
|
||||
_setState(ConnState.connected);
|
||||
onReady?.call(_sessionId ?? '', cols, rows, _resumed);
|
||||
break;
|
||||
case 'mobile_snapshot':
|
||||
final data = decoded['data'];
|
||||
if (data is Map<String, dynamic>) {
|
||||
onSnapshot?.call(Snapshot.fromJson(data));
|
||||
}
|
||||
break;
|
||||
case 'control_response':
|
||||
case 'pong':
|
||||
case 'error':
|
||||
break;
|
||||
case 'session_closed':
|
||||
onSessionClosed?.call();
|
||||
_setState(ConnState.disconnected);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送原始输入到 PTY。
|
||||
void sendInput(String data) {
|
||||
if (!isConnected || _channel == null) return;
|
||||
_channel!.sink.add(jsonEncode({'type': 'input', 'data': data}));
|
||||
}
|
||||
|
||||
/// 发送 resize。
|
||||
void sendResize(int cols, int rows) {
|
||||
if (!isConnected || _channel == null) return;
|
||||
_channel!.sink.add(
|
||||
jsonEncode({'type': 'resize', 'cols': cols, 'rows': rows}));
|
||||
}
|
||||
|
||||
/// 声明控制权。
|
||||
void claimControl() {
|
||||
if (!isConnected || _channel == null) return;
|
||||
_channel!.sink.add(jsonEncode({'type': 'claim_control'}));
|
||||
}
|
||||
|
||||
void _setState(ConnState state) {
|
||||
if (_state == state) return;
|
||||
_state = state;
|
||||
onStateChanged?.call(state);
|
||||
}
|
||||
|
||||
/// 每 30s 发送一次心跳,保持连接活跃并探测服务端。
|
||||
void _startPing() {
|
||||
_stopPing();
|
||||
_pingTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
if (isConnected && _channel != null) {
|
||||
_channel!.sink.add(jsonEncode({'type': 'ping'}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _stopPing() {
|
||||
_pingTimer?.cancel();
|
||||
_pingTimer = null;
|
||||
}
|
||||
}
|
||||
59
mobile/lib/src/theme.dart
Normal file
59
mobile/lib/src/theme.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Rtty 移动端主题。
|
||||
///
|
||||
/// 设计方向:现代终端卡片美学。深靛蓝底 + 霓虹青绿强调色,
|
||||
/// 用卡片承载命令与输出,强调可读性与触控友好。
|
||||
class RttyMobileTheme {
|
||||
RttyMobileTheme._();
|
||||
|
||||
static const Color background = Color(0xFF0D1117); // 深靛黑
|
||||
static const Color surface = Color(0xFF161C26); // 卡片底
|
||||
static const Color surfaceAlt = Color(0xFF1C2430); // 输入区
|
||||
static const Color border = Color(0xFF2A3442);
|
||||
static const Color primary = Color(0xFF3DDB93); // 霓虹青绿
|
||||
static const Color primaryDim = Color(0xFF176C4B);
|
||||
static const Color accent = Color(0xFFFFB454); // 琥珀
|
||||
static const Color danger = Color(0xFFE5534B);
|
||||
static const Color text = Color(0xFFE2E8F0);
|
||||
static const Color textDim = Color(0xFF93A1B3);
|
||||
static const Color textFaint = Color(0xFF5B6878);
|
||||
static const Color commandTag = Color(0xFF7C5CFF); // 命令标签紫
|
||||
|
||||
static ThemeData app() {
|
||||
final base = ThemeData.dark(useMaterial3: true);
|
||||
return base.copyWith(
|
||||
scaffoldBackgroundColor: background,
|
||||
colorScheme: ColorScheme.dark(
|
||||
primary: primary,
|
||||
secondary: accent,
|
||||
surface: surface,
|
||||
error: danger,
|
||||
onPrimary: const Color(0xFF07130C),
|
||||
onSurface: text,
|
||||
),
|
||||
textTheme: base.textTheme.copyWith(
|
||||
bodyMedium: base.textTheme.bodyMedium?.copyWith(
|
||||
color: text,
|
||||
fontSize: 15,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: surfaceAlt,
|
||||
hintStyle: const TextStyle(color: textFaint, fontSize: 15),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: const BorderSide(color: primaryDim, width: 1.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user