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

View File

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

View File

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

View File

@@ -25,7 +25,8 @@ void main() {
String? sessionId; String? sessionId;
final raw = StringBuffer(); final raw = StringBuffer();
backend.onStateChanged = (s) => states.add(s); 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) { backend.output.listen((Uint8List b) {
raw.write(String.fromCharCodes(b)); raw.write(String.fromCharCodes(b));
}); });

45
mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

33
mobile/.metadata Normal file
View File

@@ -0,0 +1,33 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "ad70ec4617166f1c38e5d2bfd388af71fda14f06"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: android
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: ios
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

17
mobile/README.md Normal file
View File

@@ -0,0 +1,17 @@
# rtty_mobile
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View File

@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
mobile/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,45 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.rtty_mobile"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.rtty_mobile"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,48 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 访问服务端 WebSocket 需要网络权限。 -->
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="rtty_mobile"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,5 @@
package com.example.rtty_mobile
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip

View File

@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")

34
mobile/ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1,644 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.rttyMobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.rttyMobile.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.rttyMobile.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.rttyMobile.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.rttyMobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.rttyMobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}

View File

@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View File

@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Rtty Mobile</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>rtty_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}

View File

@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

452
mobile/lib/main.dart Normal file
View 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),
),
);
}
}

View 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
View 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),
),
),
);
}
}

253
mobile/pubspec.lock Normal file
View File

@@ -0,0 +1,253 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.9"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.3"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.flutter-io.cn"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.1.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.18.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.9.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.11"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.flutter-io.cn"
source: hosted
version: "15.2.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: "direct main"
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.3"
sdks:
dart: ">=3.12.2 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"

90
mobile/pubspec.yaml Normal file
View File

@@ -0,0 +1,90 @@
name: rtty_mobile
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.12.2
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
web_socket_channel: ^3.0.3
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package

View File

@@ -0,0 +1,14 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rtty_mobile/main.dart';
void main() {
testWidgets('Rtty mobile renders terminal page', (WidgetTester tester) async {
await tester.pumpWidget(const RttyMobileApp());
// 未连接状态应显示连接面板与提示文案。
expect(find.text('未连接'), findsWidgets);
expect(find.text('连接'), findsOneWidget);
expect(find.text('输入命令,回车发送'), findsOneWidget);
});
}

View File

@@ -12,6 +12,9 @@ pub struct ServerConfig {
pub host: String, pub host: String,
/// 监听端口。 /// 监听端口。
pub port: u16, pub port: u16,
/// 可选访问令牌;设置后所有 WebSocket 连接都必须携带 `?token=` 参数。
/// 未设置None表示不鉴权仅适合内网/本机使用。
pub token: Option<String>,
/// 默认启动的 Shell 程序。 /// 默认启动的 Shell 程序。
pub shell: String, pub shell: String,
/// 终端默认列数。 /// 终端默认列数。
@@ -31,6 +34,7 @@ impl Default for ServerConfig {
Self { Self {
host: "0.0.0.0".into(), host: "0.0.0.0".into(),
port: 8080, port: 8080,
token: None,
shell: default_shell(), shell: default_shell(),
cols: 120, cols: 120,
rows: 32, rows: 32,
@@ -54,6 +58,10 @@ impl ServerConfig {
{ {
cfg.port = p; cfg.port = p;
} }
if let Ok(v) = env::var("RTTY_TOKEN") {
// 空字符串视为未设置。
cfg.token = (!v.is_empty()).then_some(v);
}
if let Ok(v) = env::var("RTTY_SHELL") { if let Ok(v) = env::var("RTTY_SHELL") {
cfg.shell = v; cfg.shell = v;
} }

View File

@@ -6,6 +6,7 @@
//! - 为移动端生成语义化 JSON 快照,为断线重连提供状态恢复。 //! - 为移动端生成语义化 JSON 快照,为断线重连提供状态恢复。
use std::io::Write; use std::io::Write;
use std::fmt::Write as FmtWrite;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use alacritty_terminal::event::{Event, EventListener}; use alacritty_terminal::event::{Event, EventListener};
@@ -13,7 +14,7 @@ use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::index::{Column, Point}; use alacritty_terminal::index::{Column, Point};
use alacritty_terminal::term::cell::Flags; use alacritty_terminal::term::cell::Flags;
use alacritty_terminal::term::{point_to_viewport, viewport_to_point, Config, Term}; use alacritty_terminal::term::{point_to_viewport, viewport_to_point, Config, Term};
use alacritty_terminal::vte::ansi::Processor; use alacritty_terminal::vte::ansi::{Color, Processor};
use crate::ws::protocol::MobileSnapshot; use crate::ws::protocol::MobileSnapshot;
@@ -38,6 +39,17 @@ impl Dimensions for TermSize {
} }
} }
/// 参与 SGR 样式输出的 flag 子集(重绘时只比较/输出这些样式位,
/// 忽略 WIDE_CHAR、WRAPLINE 等纯布局标记)。
const STYLE_FLAGS: Flags = Flags::INVERSE
.union(Flags::BOLD)
.union(Flags::ITALIC)
.union(Flags::UNDERLINE)
.union(Flags::DIM)
.union(Flags::STRIKEOUT)
.union(Flags::HIDDEN)
.union(Flags::ALL_UNDERLINES);
/// 事件监听器:把 alacritty 要求回写给 Shell 的数据写入 PTY。 /// 事件监听器:把 alacritty 要求回写给 Shell 的数据写入 PTY。
#[derive(Clone)] #[derive(Clone)]
pub struct SessionListener { pub struct SessionListener {
@@ -148,4 +160,119 @@ impl TerminalEngine {
pub fn rows(&self) -> usize { pub fn rows(&self) -> usize {
self.term.grid().screen_lines() self.term.grid().screen_lines()
} }
/// 将当前屏幕序列化为 ANSI 重绘序列(清屏 + 逐行 SGR 着色 + 光标归位)。
///
/// 桌面端在(重)连接时收到该序列,本地 Alacritty 引擎即可恢复服务端
/// “真相源”的屏幕状态——这是断线重连后画面恢复的关键一步。
pub fn redraw_ansi(&self) -> Vec<u8> {
let grid = self.term.grid();
let rows = grid.screen_lines();
// 始终重绘当前屏幕视口(滚动历史不参与,保持轻量)。
let display_offset = 0usize;
let mut out = String::with_capacity(rows * 64);
// 清屏并把光标移到左上角。
out.push_str("\x1b[2J\x1b[H");
for line_idx in 0..rows {
let grid_point = viewport_to_point(display_offset, Point::new(line_idx, Column(0)));
let row = &grid[grid_point.line];
// 第一遍:收集本行可见单元格,并记住最后一个非空格的位置,
// 便于行尾用“擦除到行末”代替输出大量空格。
let mut cells: Vec<(char, Color, Color, Flags)> = Vec::with_capacity(row.len());
let mut last_non_space = 0usize;
for cell in row {
if cell
.flags
.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER | Flags::HIDDEN)
{
continue;
}
let style = cell.flags & STYLE_FLAGS;
cells.push((cell.c, cell.fg, cell.bg, style));
if cell.c != ' ' {
last_non_space = cells.len();
}
// 零宽字符(组合音标等)必须紧跟主字符输出。
if let Some(zw) = cell.zerowidth() {
for &c in zw {
cells.push((c, cell.fg, cell.bg, style));
last_non_space = cells.len();
}
}
}
// 第二遍:按“相邻同风格”分组输出,风格变化时才发出 SGR 序列。
let mut cur_style: Option<(Color, Color, Flags)> = None;
for &(c, fg, bg, style) in cells.iter().take(last_non_space) {
let style_key = (fg, bg, style);
if cur_style != Some(style_key) {
cur_style = Some(style_key);
out.push_str("\x1b[0m\x1b[0");
push_flags_sgr(&mut out, style);
push_color_sgr(&mut out, fg, true);
push_color_sgr(&mut out, bg, false);
out.push('m');
}
out.push(c);
}
// 行尾擦除到行末,保证背景色完整且无残留字符。
out.push_str("\x1b[K\r\n");
}
// 最后把光标放回服务端记录的位置。
if let Some(p) = point_to_viewport(display_offset, grid.cursor.point) {
let _ = write!(out, "\x1b[{};{}H", p.line + 1, p.column.0 + 1);
}
out.into_bytes()
}
}
/// 把颜色追加为 SGR 参数含前导分号fg=true 表示前景色。
fn push_color_sgr(out: &mut String, color: Color, fg: bool) {
let base = if fg { 38 } else { 48 };
match color {
// 标准 16 色:用 256 色索引表达,兼容性最好。
Color::Named(n) if (n as usize) < 16 => {
let _ = write!(out, ";{base};5;{}", n as u8);
}
// Foreground/Background/Dim 等特殊色:直接回落到默认前景/背景。
Color::Named(_) => {
out.push_str(if fg { ";39" } else { ";49" });
}
Color::Indexed(i) => {
let _ = write!(out, ";{base};5;{i}");
}
Color::Spec(rgb) => {
let _ = write!(out, ";{base};2;{};{};{}", rgb.r, rgb.g, rgb.b);
}
}
}
/// 把样式 flag 追加为 SGR 参数(含前导分号)。
fn push_flags_sgr(out: &mut String, flags: Flags) {
if flags.contains(Flags::BOLD) {
out.push_str(";1");
}
if flags.contains(Flags::DIM) {
out.push_str(";2");
}
if flags.contains(Flags::ITALIC) {
out.push_str(";3");
}
if flags.intersects(Flags::ALL_UNDERLINES) {
out.push_str(";4");
}
if flags.contains(Flags::INVERSE) {
out.push_str(";7");
}
if flags.contains(Flags::HIDDEN) {
out.push_str(";8");
}
if flags.contains(Flags::STRIKEOUT) {
out.push_str(";9");
}
} }

View File

@@ -3,6 +3,16 @@
//! 基于 [`portable_pty`] 创建伪终端并拉起子进程Shell提供读写与 resize 能力。 //! 基于 [`portable_pty`] 创建伪终端并拉起子进程Shell提供读写与 resize 能力。
//! 读取端由服务端读取任务独占持有,写入端与 master 句柄可被多个客户端共享。 //! 读取端由服务端读取任务独占持有,写入端与 master 句柄可被多个客户端共享。
// ── 背景知识:什么是 PTY────────────────────────────────────────────
// PTYpseudo-terminal伪终端是操作系统提供的一对"虚拟终端"
// - Slave从端挂到子进程上子进程把它当作 stdin/stdout/stderr 使用;
// - Master主端由服务端持有用来读取子进程写到"屏幕"上的输出,
// 以及把键盘输入"敲"进终端交给子进程。
// 这样一来Shellbash / powershell 等)会以为自己运行在一个真实终端里,
// 因此会启用交互模式、输出 ANSI 转义序列(颜色、光标移动等),
// 而服务端可以在 Master 端拿到这些原始字节流。
// ─────────────────────────────────────────────────────────────────────
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -10,34 +20,65 @@ use anyhow::{Context, Result};
use portable_pty::{Child, MasterPty, PtyPair, PtySize}; use portable_pty::{Child, MasterPty, PtyPair, PtySize};
/// 一个已启动的 PTY 会话。 /// 一个已启动的 PTY 会话。
///
/// 它代表"一个运行中的 shell 及其背后的终端"。整个数据流是这样的:
/// 子进程输出 → reader由后台读取任务独占→ 交给 TerminalEngine 解析;
/// 客户端输入 → `write()` → writer → 子进程 stdin。
pub struct PtySession { pub struct PtySession {
/// Master 端句柄,用于 resize / 获取大小。 /// Master 端句柄,用于 resize / 获取大小。
///
/// 注意:写入端被 `take_writer()` 取走之后,这个 master 句柄本身
/// 就不再负责读写,只保留控制能力(改尺寸、查状态)。
master: Box<dyn MasterPty + Send>, master: Box<dyn MasterPty + Send>,
/// 从 Slave 端读取输出的流。独占,由读取任务持有。 /// 从 Master 端读取输出的流。
///
/// 设计为 `Option` 是为了支持"一次性取出":读取端会被交给一个独立的
/// 后台读取任务独占持有(尤其 Windows 的 ConPTY 要求单一读取线程),
/// 取出之后本结构体里就不再保留读取能力(见 [`Self::take_reader`])。
reader: Option<Box<dyn Read + Send>>, reader: Option<Box<dyn Read + Send>>,
/// 写入 Slave 端的流可被多个客户端共享(加锁)。 /// 写入 Master 端的流可被多个客户端共享(加锁)。
///
/// `Arc` 让多个持有者(例如多个 WebSocket 连接处理任务)都能拿到同一份
/// 写入端;`Mutex` 保证同一时刻只有一个线程在真正写入,避免数据交错。
writer: Arc<Mutex<Box<dyn Write + Send>>>, writer: Arc<Mutex<Box<dyn Write + Send>>>,
/// 子进程句柄,用于检测退出 / 终止。 /// 子进程句柄,用于检测退出 / 终止。
child: Box<dyn Child + Send + Sync>, child: Box<dyn Child + Send + Sync>,
/// 当前尺寸。 /// 当前尺寸(行 / 列),`resize()` 时会同步更新这份缓存
size: PtySize, size: PtySize,
} }
impl PtySession { impl PtySession {
/// 以指定 Shell 与初始尺寸创建一个 PTY 会话。 /// 以指定 Shell 与初始尺寸创建一个 PTY 会话。
pub fn new(shell: &str, cols: u16, rows: u16) -> Result<Self> { pub fn new(shell: &str, cols: u16, rows: u16) -> Result<Self> {
// 1. 组装初始尺寸:行列数来自客户端(首次连接时上报),
// 像素尺寸填 0普通终端程序用不到。
let size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }; let size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
// 2. 获取当前平台的原生 PTY 实现:
// - Linux/macOS 上是 Unix ptyopenpty
// - Windows 上是 ConPTY。
let pty_system = portable_pty::native_pty_system(); let pty_system = portable_pty::native_pty_system();
// 3. openpty 真正创建一对 master/slave 终端,并应用初始尺寸。
// 返回的 PtyPair 里同时装着 master 与 slave 两端。
let pair = pty_system.openpty(size).context("failed to open pty")?; let pair = pty_system.openpty(size).context("failed to open pty")?;
// 4. 在 slave 端拉起 shell 子进程,并把子进程的 stdio 接到 PTY 上。
let child = spawn_child(&pair, shell).context("failed to spawn shell")?; let child = spawn_child(&pair, shell).context("failed to spawn shell")?;
// 5. 从 master 端克隆出一个独立的读取流。
// 是"克隆"而不是直接拿 mastermaster 本身还要留下来做 resize
// 所以读取走另一个句柄Windows ConPTY 下内部会启动线程持续泵数据)。
let reader = pair.master.try_clone_reader().context("failed to clone pty reader")?; let reader = pair.master.try_clone_reader().context("failed to clone pty reader")?;
// 6. 从 master 上取走写入流take 之后 master 不再负责写入)。
let writer = pair.master.take_writer().context("failed to take pty writer")?; let writer = pair.master.take_writer().context("failed to take pty writer")?;
Ok(Self { Ok(Self {
master: pair.master, master: pair.master,
reader: Some(reader), reader: Some(reader),
// 用 Arc<Mutex<>> 包一层:多个客户端连接共享同一个写入端,
// 互斥锁保证同一时刻只有一个线程在写。
writer: Arc::new(Mutex::new(writer)), writer: Arc::new(Mutex::new(writer)),
child, child,
size, size,
@@ -45,54 +86,94 @@ impl PtySession {
} }
/// 取出读取端,供 PTY 读取任务独占使用。取走后不可再次调用。 /// 取出读取端,供 PTY 读取任务独占使用。取走后不可再次调用。
///
/// 为什么是"独占"而不是共享?
/// 1. 读取必须持续不断地进行,否则子进程输出一多就会阻塞在管道上;
/// 2. portable-pty 在 Windows 上要求读取由单一专用线程完成。
/// 所以这里用 `Option::take` 把所有权移交出去self.reader 变回 None
/// 会话本身不再持有读取端,调用方拿到后负责持续读取并喂给终端引擎。
pub fn take_reader(&mut self) -> Option<Box<dyn Read + Send>> { pub fn take_reader(&mut self) -> Option<Box<dyn Read + Send>> {
self.reader.take() self.reader.take()
} }
/// 获取共享写入端。 /// 获取共享写入端。
///
/// 返回的是 Arc 引用,调用方(一般是 WebSocket 处理层)可以克隆这个
/// `Arc<Mutex<...>>` 到多个任务里,从而共享同一个写入端。
pub fn writer(&self) -> &Arc<Mutex<Box<dyn Write + Send>>> { pub fn writer(&self) -> &Arc<Mutex<Box<dyn Write + Send>>> {
&self.writer &self.writer
} }
/// 将数据写入 Slave 端(发送给 Shell)。 /// 将数据写入 Master 端(最终送达 Shell 的 stdin)。
///
/// 这是"客户端输入 → 服务端 → 子进程"这条链路的落点:
/// 任何客户端发来的键盘输入最终都会调用到这里。
pub fn write(&self, data: &[u8]) -> Result<()> { pub fn write(&self, data: &[u8]) -> Result<()> {
// 先加锁,避免多个连接同时写造成数据交错/竞态。
let mut w = self let mut w = self
.writer .writer
.lock() .lock()
.map_err(|_| anyhow::anyhow!("pty writer poisoned"))?; .map_err(|_| anyhow::anyhow!("pty writer poisoned"))?;
// write_all要么全部写完要么返回错误内部会循环补完短写
w.write_all(data)?; w.write_all(data)?;
// flush确保数据真正发到内核/管道,而不是留在用户态缓冲里。
w.flush()?; w.flush()?;
Ok(()) Ok(())
} }
/// 调整 PTY 尺寸(通知内核与子进程)。 /// 调整 PTY 尺寸(通知内核与子进程)。
///
/// 客户端窗口大小变化时调用:把新行列数同步给 PTY 内核对象,
/// 子进程会收到 SIGWINCH 信号Unix或等效通知从而重新排版
/// (例如 vim / htop 会立刻按新行列数重绘)。
pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> { pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
// 先更新本地缓存,便于随时查询当前尺寸。
self.size.rows = rows; self.size.rows = rows;
self.size.cols = cols; self.size.cols = cols;
// 再真正执行 resize通知内核与子进程。
self.master.resize(self.size).context("failed to resize pty") self.master.resize(self.size).context("failed to resize pty")
} }
/// 当前 PTY 尺寸。 /// 当前 PTY 尺寸,返回 (列, 行)
pub fn size(&self) -> (u16, u16) { pub fn size(&self) -> (u16, u16) {
(self.size.cols, self.size.rows) (self.size.cols, self.size.rows)
} }
/// 子进程是否已退出。 /// 子进程是否已退出(非阻塞轮询)
///
/// - `Ok(None)` 表示进程仍在运行;
/// - `Ok(Some(status))` 表示已退出,并拿到退出状态。
/// 服务端后台任务定期调用它探测 shell 是否退出,用于清理会话。
pub fn try_wait(&mut self) -> Result<Option<portable_pty::ExitStatus>> { pub fn try_wait(&mut self) -> Result<Option<portable_pty::ExitStatus>> {
self.child.try_wait().context("failed to poll child") self.child.try_wait().context("failed to poll child")
} }
/// 终止子进程。 /// 终止子进程。
///
/// 会话关闭 / 客户端主动断开时调用,直接杀死 shell
/// 避免留下孤儿进程继续驻留。
pub fn kill(&mut self) -> Result<()> { pub fn kill(&mut self) -> Result<()> {
self.child.kill().context("failed to kill child") self.child.kill().context("failed to kill child")
} }
} }
/// 拉起子进程,并把 Stdio 重定向到 PTY 的 Slave 端。 /// 拉起子进程,并把 Stdio 重定向到 PTY 的 Slave 端。
///
/// 关键点:
/// - `CommandBuilder` 是 portable_pty 对系统命令构造器的跨平台封装;
/// - `pair.slave.spawn_command` 会把子进程的 stdin/stdout/stderr
/// 全部接到 slave 端,这样 shell 的输入输出就都走 PTY 了。
fn spawn_child(pair: &PtyPair, shell: &str) -> Result<Box<dyn Child + Send + Sync>> { fn spawn_child(pair: &PtyPair, shell: &str) -> Result<Box<dyn Child + Send + Sync>> {
let mut cmd = portable_pty::CommandBuilder::new(shell); let mut cmd = portable_pty::CommandBuilder::new(shell);
// 让 Shell 以交互方式运行。
// 告诉子进程"你面前是一个支持 256 色的 xterm 兼容终端"。
// 这会让 shell 及其中的 TUI 程序vim/htop/tmux输出正确的
// ANSI 颜色与光标控制序列,否则它们可能退化成 16 色甚至无颜色。
// Shell 的"交互模式"本身由 stdio 是 TTY 决定,即上面 spawn 在 slave 上,
// 这里的 TERM 只是告诉程序终端的类型与能力。)
cmd.env("TERM", "xterm-256color"); cmd.env("TERM", "xterm-256color");
// 真正拉起子进程。
let child = pair.slave.spawn_command(cmd)?; let child = pair.slave.spawn_command(cmd)?;
Ok(child) Ok(child)
} }

View File

@@ -68,6 +68,8 @@ pub struct Session {
pub control: Arc<Mutex<Option<String>>>, pub control: Arc<Mutex<Option<String>>>,
/// 当前连接的客户端数。 /// 当前连接的客户端数。
pub clients: AtomicUsize, pub clients: AtomicUsize,
/// 当前连接的移动端客户端数(>0 时才生成语义快照,避免无谓开销)。
pub mobile_clients: AtomicUsize,
} }
/// WebSocket 路由入口。 /// WebSocket 路由入口。
@@ -88,9 +90,19 @@ async fn handle_socket(
let client_id = format!("client-{}", CLIENT_SEQ.fetch_add(1, Ordering::Relaxed)); let client_id = format!("client-{}", CLIENT_SEQ.fetch_add(1, Ordering::Relaxed));
let kind = ClientKind::from_params(&params); let kind = ClientKind::from_params(&params);
// 最小鉴权:配置了 RTTY_TOKEN 时,所有连接必须携带匹配的 ?token= 参数。
if let Some(expected) = &state.config.token {
let actual = params.get("token").map(|s| s.as_str());
if actual != Some(expected.as_str()) {
let msg = ServerMessage::Error { message: "invalid or missing token".into() };
let _ = send_text(socket, msg.to_json()).await;
return;
}
}
let requested = params.get("session").cloned().unwrap_or_default(); let requested = params.get("session").cloned().unwrap_or_default();
let session = match get_or_create_session(&state, &requested) { let (session, resumed) = match get_or_create_session(&state, &requested) {
Ok(s) => s, Ok(pair) => pair,
Err(e) => { Err(e) => {
let msg = ServerMessage::Error { message: format!("session error: {e}") }; let msg = ServerMessage::Error { message: format!("session error: {e}") };
let _ = send_text(socket, msg.to_json()).await; let _ = send_text(socket, msg.to_json()).await;
@@ -102,12 +114,12 @@ async fn handle_socket(
// 发送就绪消息。 // 发送就绪消息。
let (cols, rows) = session.pty.lock().unwrap().size(); let (cols, rows) = session.pty.lock().unwrap().size();
let ready = ServerMessage::Ready { id: session.id.clone(), cols, rows }; let ready = ServerMessage::Ready { id: session.id.clone(), cols, rows, resumed };
if tx.send(Message::Text(ready.to_json())).await.is_err() { if tx.send(Message::Text(ready.to_json())).await.is_err() {
return; return;
} }
// 移动端在连接时获取一次语义化快照作为初始状态(桌面端走原始 ANSI 流) // 移动端在连接时获取一次语义化快照作为初始状态。
if kind == ClientKind::Mobile { if kind == ClientKind::Mobile {
let snap = session.engine.lock().unwrap().snapshot(); let snap = session.engine.lock().unwrap().snapshot();
let init = ServerMessage::MobileSnapshot { data: snap }; let init = ServerMessage::MobileSnapshot { data: snap };
@@ -129,6 +141,19 @@ async fn handle_socket(
// 订阅输出。 // 订阅输出。
let mut out_rx = session.output.subscribe(); let mut out_rx = session.output.subscribe();
session.clients.fetch_add(1, Ordering::SeqCst); session.clients.fetch_add(1, Ordering::SeqCst);
if kind == ClientKind::Mobile {
session.mobile_clients.fetch_add(1, Ordering::SeqCst);
}
// 桌面端:订阅后立即重放当前屏幕。重绘序列在引擎锁内生成并注入广播通道,
// 与读取任务发出的 Raw 帧严格有序(读取任务同样在引擎锁内发帧),
// 从而保证断线重连后画面完整恢复。
if kind == ClientKind::Desktop {
let eng = session.engine.lock().unwrap();
let redraw = eng.redraw_ansi();
let _ = session.output.send(OutputEvent::Raw(redraw));
drop(eng);
}
loop { loop {
tokio::select! { tokio::select! {
@@ -179,6 +204,9 @@ async fn handle_socket(
} }
session.clients.fetch_sub(1, Ordering::SeqCst); session.clients.fetch_sub(1, Ordering::SeqCst);
if kind == ClientKind::Mobile {
session.mobile_clients.fetch_sub(1, Ordering::SeqCst);
}
// 若已无客户端安排空闲超时清理兜底机制Windows ConPTY 下进程退出 // 若已无客户端安排空闲超时清理兜底机制Windows ConPTY 下进程退出
// 检测不可靠,依赖超时确保会话最终被释放)。 // 检测不可靠,依赖超时确保会话最终被释放)。
@@ -227,7 +255,13 @@ fn handle_client_message(
let mut pty = session.pty.lock().unwrap(); let mut pty = session.pty.lock().unwrap();
let _ = pty.resize(cols, rows); let _ = pty.resize(cols, rows);
drop(pty); drop(pty);
session.engine.lock().unwrap().resize(cols, rows);
// resize 后同样在引擎锁内完成网格调整 + 全屏重绘注入,
// 让所有桌面端按新尺寸一致重排(与读取帧保持有序)。
let mut eng = session.engine.lock().unwrap();
eng.resize(cols, rows);
let redraw = eng.redraw_ansi();
let _ = session.output.send(OutputEvent::Raw(redraw));
None None
} }
ClientMessage::ClaimControl => { ClientMessage::ClaimControl => {
@@ -240,7 +274,7 @@ fn handle_client_message(
drop(control); drop(control);
Some(ServerMessage::ControlResponse { granted, holder }.to_json()) Some(ServerMessage::ControlResponse { granted, holder }.to_json())
} }
ClientMessage::Ping => None, ClientMessage::Ping => Some(ServerMessage::Pong.to_json()),
} }
} }
@@ -248,17 +282,32 @@ fn handle_client_message(
fn get_or_create_session( fn get_or_create_session(
state: &Arc<crate::AppState>, state: &Arc<crate::AppState>,
requested: &str, requested: &str,
) -> Result<Arc<Session>> { ) -> Result<(Arc<Session>, bool)> {
if !requested.is_empty() use dashmap::mapref::entry::Entry;
&& let Some(s) = state.sessions.get(requested)
{ // 指定了会话 ID按 ID 原子地“查-建-插”。
return Ok(s.clone()); // - ID 已存在 → 恢复旧会话resumed=true
// - ID 不存在 → 以该 ID 新建会话resumed=false
// 用 Entry API 避免两个连接同时命中同一 ID 时重复创建/互相覆盖。
if !requested.is_empty() {
return match state.sessions.entry(requested.to_string()) {
Entry::Occupied(e) => Ok((e.get().clone(), true)),
Entry::Vacant(v) => {
let session = create_session(requested.to_string(), &state.config, state.clone())?;
Ok((v.insert(session).clone(), false))
}
};
} }
// 未指定会话:分配全局唯一 ID 并新建。
let id = format!("session-{}", SESSION_SEQ.fetch_add(1, Ordering::Relaxed)); let id = format!("session-{}", SESSION_SEQ.fetch_add(1, Ordering::Relaxed));
let session = create_session(id.clone(), &state.config, state.clone())?; match state.sessions.entry(id.clone()) {
state.sessions.insert(id.clone(), session.clone()); Entry::Occupied(e) => Ok((e.get().clone(), true)), // 理论极罕见
Ok(session) Entry::Vacant(v) => {
let session = create_session(id, &state.config, state.clone())?;
Ok((v.insert(session).clone(), false))
}
}
} }
/// 创建会话并启动 PTY 读取任务。 /// 创建会话并启动 PTY 读取任务。
@@ -284,6 +333,7 @@ fn create_session(
output, output,
control: Arc::new(Mutex::new(None)), control: Arc::new(Mutex::new(None)),
clients: AtomicUsize::new(0), clients: AtomicUsize::new(0),
mobile_clients: AtomicUsize::new(0),
}); });
start_pty_reader(session.clone(), reader, state); start_pty_reader(session.clone(), reader, state);
@@ -312,19 +362,24 @@ fn start_pty_reader(
}; };
let chunk = &buf[..n]; let chunk = &buf[..n];
// 喂给解析器并生成快照。 // 引擎锁内完成“喂解析 + 发帧”:保证 Raw/快照帧与重绘帧严格有序
let snapshot = { // (桌面端重连重放依赖这一不变量)。
let mut eng = match session.engine.lock() { let mut eng = match session.engine.lock() {
Ok(e) => e, Ok(e) => e,
Err(_) => break, Err(_) => break,
};
eng.feed(chunk);
eng.snapshot()
}; };
eng.feed(chunk);
// 广播原始字节 + 快照 // 广播原始 ANSI 字节(桌面端渲染)
let _ = session.output.send(OutputEvent::Raw(chunk.to_vec())); let _ = session.output.send(OutputEvent::Raw(chunk.to_vec()));
let _ = session.output.send(OutputEvent::Snapshot(snapshot));
// 惰性快照:仅当存在移动端订阅者时才生成并广播(移动端是唯一消费者),
// 避免纯桌面场景下每次读取都做全量网格序列化。
if session.mobile_clients.load(Ordering::SeqCst) > 0 {
let snap = eng.snapshot();
let _ = session.output.send(OutputEvent::Snapshot(snap));
}
drop(eng);
} }
// PTY 读到 EOFUnix 场景):触发会话结束并清理。 // PTY 读到 EOFUnix 场景):触发会话结束并清理。

View File

@@ -51,6 +51,8 @@ pub enum ServerMessage {
id: String, id: String,
cols: u16, cols: u16,
rows: u16, rows: u16,
/// 是否恢复了已存在的会话false 表示本次新建了会话)。
resumed: bool,
}, },
/// 移动端专属:解耦后的语义化屏显快照。 /// 移动端专属:解耦后的语义化屏显快照。
MobileSnapshot { MobileSnapshot {
@@ -61,6 +63,8 @@ pub enum ServerMessage {
granted: bool, granted: bool,
holder: Option<String>, holder: Option<String>,
}, },
/// 心跳应答。
Pong,
/// 服务端错误。 /// 服务端错误。
Error { Error {
message: String, message: String,