diff --git a/desktop/lib/src/connection_bar.dart b/desktop/lib/src/connection_bar.dart index e25971d..bc0dbff 100644 --- a/desktop/lib/src/connection_bar.dart +++ b/desktop/lib/src/connection_bar.dart @@ -13,8 +13,10 @@ class ConnectionBar extends StatelessWidget { required this.state, required this.hostController, required this.portController, + required this.tokenController, required this.sessionController, required this.sessionId, + required this.resumed, required this.terminalSize, required this.onConnect, required this.onDisconnect, @@ -23,8 +25,10 @@ class ConnectionBar extends StatelessWidget { final ConnState state; final TextEditingController hostController; final TextEditingController portController; + final TextEditingController tokenController; final TextEditingController sessionController; final String? sessionId; + final bool resumed; final String terminalSize; final VoidCallback onConnect; final VoidCallback onDisconnect; @@ -41,49 +45,61 @@ class ConnectionBar extends StatelessWidget { border: Border(bottom: BorderSide(color: RttyTheme.border)), ), padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - _StatusLight(state: state), - const SizedBox(width: 12), - _ConnectionLabel(state: state), - const SizedBox(width: 16), - _Divider(), - const SizedBox(width: 16), - _EditableField( - label: 'HOST', - controller: hostController, - enabled: !_connected, - width: 150, - ), - const SizedBox(width: 8), - _EditableField( - label: 'PORT', - controller: portController, - enabled: !_connected, - width: 64, - numeric: true, - ), - const SizedBox(width: 8), - if (!_connected) ...[ + // 窗口过窄时横向滚动,避免字段溢出。 + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _StatusLight(state: state), + const SizedBox(width: 12), + _ConnectionLabel(state: state), + const SizedBox(width: 16), + _Divider(), + const SizedBox(width: 16), _EditableField( - label: 'SESSION', - controller: sessionController, - enabled: true, - width: 120, - hint: 'auto', + label: 'HOST', + controller: hostController, + enabled: !_connected, + width: 130, + ), + const SizedBox(width: 8), + _EditableField( + label: 'PORT', + controller: portController, + enabled: !_connected, + width: 64, + numeric: true, + ), + const SizedBox(width: 8), + _EditableField( + label: 'TOKEN', + controller: tokenController, + enabled: !_connected, + width: 110, + hint: 'optional', + ), + const SizedBox(width: 8), + if (!_connected) ...[ + _EditableField( + label: 'SESSION', + controller: sessionController, + enabled: true, + width: 110, + hint: 'auto', + ), + ] else ...[ + _SessionBadge(sessionId: sessionId, resumed: resumed), + ], + const SizedBox(width: 24), + _TerminalSizeBadge(text: terminalSize), + const SizedBox(width: 12), + _ConnectButton( + state: state, + onConnect: onConnect, + onDisconnect: onDisconnect, ), - ] else ...[ - _SessionBadge(sessionId: sessionId), ], - const Spacer(), - _TerminalSizeBadge(text: terminalSize), - const SizedBox(width: 12), - _ConnectButton( - state: state, - onConnect: onConnect, - onDisconnect: onDisconnect, - ), - ], + ), ), ); } @@ -222,9 +238,10 @@ class _EditableField extends StatelessWidget { } class _SessionBadge extends StatelessWidget { - const _SessionBadge({required this.sessionId}); + const _SessionBadge({required this.sessionId, required this.resumed}); final String? sessionId; + final bool resumed; @override Widget build(BuildContext context) { @@ -236,7 +253,7 @@ class _SessionBadge extends StatelessWidget { border: Border.all(color: RttyTheme.border), ), child: Text( - 'SID ${sessionId ?? '—'}', + 'SID ${sessionId ?? '—'}${resumed ? ' · RESUMED' : ' · NEW'}', style: const TextStyle( color: RttyTheme.textDim, fontSize: 11, diff --git a/desktop/lib/src/rtty_pty_backend.dart b/desktop/lib/src/rtty_pty_backend.dart index 2f91739..35690d1 100644 --- a/desktop/lib/src/rtty_pty_backend.dart +++ b/desktop/lib/src/rtty_pty_backend.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter_alacritty/flutter_alacritty.dart'; @@ -9,6 +10,21 @@ import 'package:web_socket_channel/web_socket_channel.dart'; /// 连接状态。 enum ConnState { disconnected, connecting, connected, error } +/// 一次连接的参数(断线重连时复用)。 +class _ConnectParams { + const _ConnectParams({ + required this.host, + required this.port, + this.session, + this.token, + }); + + final String host; + final int port; + final String? session; + final String? token; +} + /// 将 Rtty Rust 服务端桥接为 flutter_alacritty 的 [PtyBackend]。 /// /// 这正是该库为远程(WebSocket/SSH)数据源预留的扩展点: @@ -16,6 +32,10 @@ enum ConnState { disconnected, connecting, connected, error } /// - [write]:把引擎产生的输入字节发回服务端写入远端 PTY; /// - [resize]:同步终端尺寸到服务端; /// - [exitCode]:服务端会话结束时完成。 +/// +/// 额外能力: +/// - 断线自动重连(指数退避),重连成功后服务端会重放全屏,画面无缝恢复; +/// - 每 30s 发送一次心跳,保持连接活跃并探测服务端存活。 class RttyPtyBackend implements PtyBackend { RttyPtyBackend(); @@ -32,11 +52,25 @@ class RttyPtyBackend implements PtyBackend { String? _sessionId; String? get sessionId => _sessionId; + /// 是否恢复了已存在的会话(false = 本次连接新建了会话)。 + bool _resumed = false; + bool get resumed => _resumed; + String? _error; String? get error => _error; - /// 就绪回调(收到 ready 帧,携带会话 ID 与初始尺寸)。 - void Function(String id, int cols, int rows)? onReady; + /// 用户主动断开/关闭:不再自动重连。 + bool _manualClose = true; + /// 不可恢复错误(鉴权失败 / 会话结束):不再自动重连。 + bool _fatal = false; + + int _reconnectAttempts = 0; + Timer? _reconnectTimer; + Timer? _pingTimer; + _ConnectParams? _params; + + /// 就绪回调(收到 ready 帧,携带会话 ID、初始尺寸与恢复标记)。 + void Function(String id, int cols, int rows, bool resumed)? onReady; /// 状态变化回调。 void Function(ConnState state)? onStateChanged; @@ -55,38 +89,83 @@ class RttyPtyBackend implements PtyBackend { required String host, required int port, String? session, + String? token, }) async { await close(); - + _manualClose = false; + _fatal = false; + _reconnectAttempts = 0; + _params = _ConnectParams(host: host, port: port, session: session, token: token); _setState(ConnState.connecting); _error = null; + _openChannel(); + } - final query = (session != null && session.isNotEmpty) - ? '?session=${Uri.encodeQueryComponent(session)}&client=desktop' - : '?client=desktop'; - final uri = Uri.parse('ws://$host:$port/ws$query'); + void _openChannel() { + final p = _params; + if (p == null || _manualClose || _fatal) return; + + final query = StringBuffer('?client=desktop'); + if (p.token != null && p.token!.isNotEmpty) { + query.write('&token=${Uri.encodeQueryComponent(p.token!)}'); + } + if (p.session != null && p.session!.isNotEmpty) { + query.write('&session=${Uri.encodeQueryComponent(p.session!)}'); + } + final uri = Uri.parse('ws://${p.host}:${p.port}/ws$query'); try { - _channel = IOWebSocketChannel.connect(uri); - _sub = _channel!.stream.listen( + final channel = IOWebSocketChannel.connect(uri); + _channel = channel; + _sub = channel.stream.listen( _handleIncoming, onError: (Object e) { _error = e.toString(); _setState(ConnState.error); + _scheduleReconnect(); }, - onDone: () { - if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(0); - if (_state == ConnState.connected) _setState(ConnState.disconnected); - }, + onDone: _handleDone, ); - _setState(ConnState.connected); + // 收到 ready 帧后才转 connected;期间保持 connecting。 + _startPing(); } catch (e) { _error = e.toString(); - if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(1); _setState(ConnState.error); + _scheduleReconnect(); } } + /// 连接被对端关闭(或底层错误)后的统一出口。 + void _handleDone() { + _stopPing(); + if (_manualClose) { + if (_state == ConnState.connected) _setState(ConnState.disconnected); + return; + } + if (_fatal) { + if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(1); + if (_state != ConnState.error) _setState(ConnState.error); + return; + } + // 网络断开:进入指数退避重连。 + if (_state != ConnState.disconnected) _setState(ConnState.disconnected); + _scheduleReconnect(); + } + + /// 指数退避重连:500ms → 1s → 2s → …,上限 10s;收到 ready 后重置。 + void _scheduleReconnect() { + if (_manualClose || _fatal) return; + _reconnectTimer?.cancel(); + final backoffMs = + math.min(500 * (1 << math.min(_reconnectAttempts, 5)), 10000); + _reconnectAttempts++; + _reconnectTimer = Timer(Duration(milliseconds: backoffMs), () { + if (_manualClose || _fatal) return; + _setState(ConnState.connecting); + _openChannel(); + }); + } + void _handleIncoming(dynamic message) { if (message is List) { // PC 端原始 ANSI 字节流 → 喂给引擎渲染。 @@ -108,15 +187,25 @@ class RttyPtyBackend implements PtyBackend { switch (decoded['type']) { case 'ready': _sessionId = decoded['id'] as String? ?? ''; + _resumed = decoded['resumed'] == true; final cols = (decoded['cols'] as num?)?.toInt() ?? 80; final rows = (decoded['rows'] as num?)?.toInt() ?? 24; + _reconnectAttempts = 0; // 重连成功,重置退避。 _setState(ConnState.connected); - onReady?.call(_sessionId ?? '', cols, rows); + onReady?.call(_sessionId ?? '', cols, rows, _resumed); case 'mobile_snapshot': // 桌面端使用原始 ANSI 渲染,忽略语义化快照。 break; + case 'pong': + // 心跳应答,无需处理。 + break; case 'error': + _error = decoded['message'] as String? ?? 'server error'; + _fatal = true; // 鉴权失败等不可恢复错误,不再重连。 + if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(1); + _setState(ConnState.error); case 'session_closed': + _fatal = true; // 会话已结束,不再重连。 if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(0); _setState(ConnState.disconnected); default: @@ -127,10 +216,8 @@ class RttyPtyBackend implements PtyBackend { @override void write(Uint8List data) { if (_state != ConnState.connected || _channel == null) return; - _channel!.sink.add(jsonEncode({ - 'type': 'input', - 'data': utf8.decode(data, allowMalformed: true), - })); + // 二进制帧原样透传:避免经 UTF-8 字符串中转时损坏任意字节序列。 + _channel!.sink.add(data); } @override @@ -145,15 +232,39 @@ class RttyPtyBackend implements PtyBackend { close(); } - /// 关闭连接并释放资源。 + /// 用户主动关闭连接并释放资源。 Future 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 _teardownChannel() async { await _sub?.cancel(); _sub = null; await _channel?.sink.close(); _channel = null; - _sessionId = null; - if (!_exitCodeCtl.isCompleted) _exitCodeCtl.complete(0); - if (_state != ConnState.disconnected) _setState(ConnState.disconnected); + } + + void _startPing() { + _stopPing(); + _pingTimer = Timer.periodic(const Duration(seconds: 30), (_) { + if (_state == ConnState.connected && _channel != null) { + _channel!.sink.add(jsonEncode({'type': 'ping'})); + } + }); + } + + void _stopPing() { + _pingTimer?.cancel(); + _pingTimer = null; } void _setState(ConnState state) { diff --git a/desktop/lib/src/terminal_screen.dart b/desktop/lib/src/terminal_screen.dart index 488c927..0396737 100644 --- a/desktop/lib/src/terminal_screen.dart +++ b/desktop/lib/src/terminal_screen.dart @@ -38,10 +38,12 @@ class _TerminalScreenState extends State { final _host = TextEditingController(text: '127.0.0.1'); final _port = TextEditingController(text: '8080'); + final _token = TextEditingController(); final _session = TextEditingController(); ConnState _state = ConnState.disconnected; String? _sessionId; + bool _resumed = false; int _cols = 0; int _rows = 0; @@ -56,6 +58,7 @@ class _TerminalScreenState extends State { _tearDownSession(); _host.dispose(); _port.dispose(); + _token.dispose(); _session.dispose(); _controller.dispose(); _focus.dispose(); @@ -65,6 +68,7 @@ class _TerminalScreenState extends State { Future _connect() async { final host = _host.text.trim(); final port = int.tryParse(_port.text.trim()) ?? 8080; + final token = _token.text.trim(); final session = _session.text.trim(); if (host.isEmpty) return; @@ -91,16 +95,17 @@ class _TerminalScreenState extends State { _sessionId = null; } }; - backend.onReady = (id, cols, rows) { + backend.onReady = (id, cols, rows, resumed) { if (!mounted) return; setState(() { _sessionId = id; + _resumed = resumed; _cols = cols; _rows = rows; }); }; - await backend.connect(host: host, port: port, session: session); + await backend.connect(host: host, port: port, session: session, token: token); } void _disconnect() => _tearDownSession(); @@ -124,6 +129,7 @@ class _TerminalScreenState extends State { _engine?.dispose(); _engine = null; _sessionId = null; + _resumed = false; _state = ConnState.disconnected; } @@ -158,8 +164,10 @@ class _TerminalScreenState extends State { state: _state, hostController: _host, portController: _port, + tokenController: _token, sessionController: _session, sessionId: _sessionId, + resumed: _resumed, terminalSize: sizeText, onConnect: _connect, onDisconnect: _disconnect, diff --git a/desktop/test/e2e_ws_test.dart b/desktop/test/e2e_ws_test.dart index d98309f..801167e 100644 --- a/desktop/test/e2e_ws_test.dart +++ b/desktop/test/e2e_ws_test.dart @@ -25,7 +25,8 @@ void main() { String? sessionId; final raw = StringBuffer(); backend.onStateChanged = (s) => states.add(s); - backend.onReady = (id, cols, rows) => sessionId = '$id:$cols:$rows'; + backend.onReady = (id, cols, rows, resumed) => + sessionId = '$id:$cols:$rows:$resumed'; backend.output.listen((Uint8List b) { raw.write(String.fromCharCodes(b)); }); diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/mobile/.gitignore @@ -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 diff --git a/mobile/.metadata b/mobile/.metadata new file mode 100644 index 0000000..954d671 --- /dev/null +++ b/mobile/.metadata @@ -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' diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..27922e1 --- /dev/null +++ b/mobile/README.md @@ -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. diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/mobile/analysis_options.yaml @@ -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 diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/mobile/android/.gitignore @@ -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 diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts new file mode 100644 index 0000000..8c25dc1 --- /dev/null +++ b/mobile/android/app/build.gradle.kts @@ -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 = "../.." +} diff --git a/mobile/android/app/src/debug/AndroidManifest.xml b/mobile/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/mobile/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..60661a2 --- /dev/null +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/main/kotlin/com/example/rtty_mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/example/rtty_mobile/MainActivity.kt new file mode 100644 index 0000000..2a41e4b --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/example/rtty_mobile/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.rtty_mobile + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/mobile/android/app/src/main/res/drawable-v21/launch_background.xml b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/launch_background.xml b/mobile/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/values-night/styles.xml b/mobile/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/mobile/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/mobile/android/app/src/profile/AndroidManifest.xml b/mobile/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/mobile/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile/android/build.gradle.kts b/mobile/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/mobile/android/build.gradle.kts @@ -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("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/mobile/android/gradle.properties @@ -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 diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/mobile/android/settings.gradle.kts b/mobile/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/mobile/android/settings.gradle.kts @@ -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") diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/mobile/ios/.gitignore @@ -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 diff --git a/mobile/ios/Flutter/AppFrameworkInfo.plist b/mobile/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/mobile/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/mobile/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/mobile/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..b7df281 --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 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 = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 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 = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* 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 = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 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 = ""; + }; +/* 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 = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata b/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/mobile/ios/Runner/AppDelegate.swift @@ -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) + } +} diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" + } +} diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -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" + } +} diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -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. \ No newline at end of file diff --git a/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard b/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/ios/Runner/Base.lproj/Main.storyboard b/mobile/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/mobile/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist new file mode 100644 index 0000000..dcc9255 --- /dev/null +++ b/mobile/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Rtty Mobile + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + rtty_mobile + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/mobile/ios/Runner/Runner-Bridging-Header.h b/mobile/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/mobile/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/mobile/ios/Runner/SceneDelegate.swift b/mobile/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/mobile/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -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. + } + +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart new file mode 100644 index 0000000..c27b1bc --- /dev/null +++ b/mobile/lib/main.dart @@ -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 createState() => _TerminalPageState(); +} + +class _TerminalPageState extends State { + 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 _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 _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 _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), + ), + ); + } +} diff --git a/mobile/lib/src/rtty_mobile_client.dart b/mobile/lib/src/rtty_mobile_client.dart new file mode 100644 index 0000000..b1a0cee --- /dev/null +++ b/mobile/lib/src/rtty_mobile_client.dart @@ -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 lines; + + factory Snapshot.fromJson(Map 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 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 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) 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) { + 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; + } +} diff --git a/mobile/lib/src/theme.dart b/mobile/lib/src/theme.dart new file mode 100644 index 0000000..78c38d0 --- /dev/null +++ b/mobile/lib/src/theme.dart @@ -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), + ), + ), + ); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock new file mode 100644 index 0000000..734476f --- /dev/null +++ b/mobile/pubspec.lock @@ -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" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml new file mode 100644 index 0000000..e9a535d --- /dev/null +++ b/mobile/pubspec.yaml @@ -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 diff --git a/mobile/test/widget_test.dart b/mobile/test/widget_test.dart new file mode 100644 index 0000000..c6b0b6c --- /dev/null +++ b/mobile/test/widget_test.dart @@ -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); + }); +} diff --git a/src/config.rs b/src/config.rs index 242e253..8d5bff9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,6 +12,9 @@ pub struct ServerConfig { pub host: String, /// 监听端口。 pub port: u16, + /// 可选访问令牌;设置后所有 WebSocket 连接都必须携带 `?token=` 参数。 + /// 未设置(None)表示不鉴权,仅适合内网/本机使用。 + pub token: Option, /// 默认启动的 Shell 程序。 pub shell: String, /// 终端默认列数。 @@ -31,6 +34,7 @@ impl Default for ServerConfig { Self { host: "0.0.0.0".into(), port: 8080, + token: None, shell: default_shell(), cols: 120, rows: 32, @@ -54,6 +58,10 @@ impl ServerConfig { { 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") { cfg.shell = v; } diff --git a/src/terminal/engine.rs b/src/terminal/engine.rs index 32a5e04..e6caa2c 100644 --- a/src/terminal/engine.rs +++ b/src/terminal/engine.rs @@ -6,6 +6,7 @@ //! - 为移动端生成语义化 JSON 快照,为断线重连提供状态恢复。 use std::io::Write; +use std::fmt::Write as FmtWrite; use std::sync::{Arc, Mutex}; use alacritty_terminal::event::{Event, EventListener}; @@ -13,7 +14,7 @@ use alacritty_terminal::grid::Dimensions; use alacritty_terminal::index::{Column, Point}; use alacritty_terminal::term::cell::Flags; 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; @@ -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。 #[derive(Clone)] pub struct SessionListener { @@ -148,4 +160,119 @@ impl TerminalEngine { pub fn rows(&self) -> usize { self.term.grid().screen_lines() } + + /// 将当前屏幕序列化为 ANSI 重绘序列(清屏 + 逐行 SGR 着色 + 光标归位)。 + /// + /// 桌面端在(重)连接时收到该序列,本地 Alacritty 引擎即可恢复服务端 + /// “真相源”的屏幕状态——这是断线重连后画面恢复的关键一步。 + pub fn redraw_ansi(&self) -> Vec { + 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"); + } } diff --git a/src/terminal/pty.rs b/src/terminal/pty.rs index 55ccebd..248d0d4 100644 --- a/src/terminal/pty.rs +++ b/src/terminal/pty.rs @@ -3,6 +3,16 @@ //! 基于 [`portable_pty`] 创建伪终端并拉起子进程(Shell),提供读写与 resize 能力。 //! 读取端由服务端读取任务独占持有,写入端与 master 句柄可被多个客户端共享。 +// ── 背景知识:什么是 PTY?──────────────────────────────────────────── +// PTY(pseudo-terminal,伪终端)是操作系统提供的一对"虚拟终端": +// - Slave(从端):挂到子进程上,子进程把它当作 stdin/stdout/stderr 使用; +// - Master(主端):由服务端持有,用来读取子进程写到"屏幕"上的输出, +// 以及把键盘输入"敲"进终端交给子进程。 +// 这样一来,Shell(bash / powershell 等)会以为自己运行在一个真实终端里, +// 因此会启用交互模式、输出 ANSI 转义序列(颜色、光标移动等), +// 而服务端可以在 Master 端拿到这些原始字节流。 +// ───────────────────────────────────────────────────────────────────── + use std::io::{Read, Write}; use std::sync::{Arc, Mutex}; @@ -10,34 +20,65 @@ use anyhow::{Context, Result}; use portable_pty::{Child, MasterPty, PtyPair, PtySize}; /// 一个已启动的 PTY 会话。 +/// +/// 它代表"一个运行中的 shell 及其背后的终端"。整个数据流是这样的: +/// 子进程输出 → reader(由后台读取任务独占)→ 交给 TerminalEngine 解析; +/// 客户端输入 → `write()` → writer → 子进程 stdin。 pub struct PtySession { /// Master 端句柄,用于 resize / 获取大小。 + /// + /// 注意:写入端被 `take_writer()` 取走之后,这个 master 句柄本身 + /// 就不再负责读写,只保留控制能力(改尺寸、查状态)。 master: Box, - /// 从 Slave 端读取输出的流。独占,由读取任务持有。 + /// 从 Master 端读取输出的流。 + /// + /// 设计为 `Option` 是为了支持"一次性取出":读取端会被交给一个独立的 + /// 后台读取任务独占持有(尤其 Windows 的 ConPTY 要求单一读取线程), + /// 取出之后本结构体里就不再保留读取能力(见 [`Self::take_reader`])。 reader: Option>, - /// 写入 Slave 端的流。可被多个客户端共享(加锁)。 + /// 写入 Master 端的流,可被多个客户端共享(加锁)。 + /// + /// `Arc` 让多个持有者(例如多个 WebSocket 连接处理任务)都能拿到同一份 + /// 写入端;`Mutex` 保证同一时刻只有一个线程在真正写入,避免数据交错。 writer: Arc>>, /// 子进程句柄,用于检测退出 / 终止。 child: Box, - /// 当前尺寸。 + /// 当前尺寸(行 / 列),`resize()` 时会同步更新这份缓存。 size: PtySize, } impl PtySession { /// 以指定 Shell 与初始尺寸创建一个 PTY 会话。 pub fn new(shell: &str, cols: u16, rows: u16) -> Result { + // 1. 组装初始尺寸:行列数来自客户端(首次连接时上报), + // 像素尺寸填 0,普通终端程序用不到。 let size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }; + // 2. 获取当前平台的原生 PTY 实现: + // - Linux/macOS 上是 Unix pty(openpty); + // - Windows 上是 ConPTY。 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")?; + // 4. 在 slave 端拉起 shell 子进程,并把子进程的 stdio 接到 PTY 上。 let child = spawn_child(&pair, shell).context("failed to spawn shell")?; + + // 5. 从 master 端克隆出一个独立的读取流。 + // 是"克隆"而不是直接拿 master:master 本身还要留下来做 resize, + // 所以读取走另一个句柄(Windows ConPTY 下内部会启动线程持续泵数据)。 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")?; Ok(Self { master: pair.master, reader: Some(reader), + // 用 Arc> 包一层:多个客户端连接共享同一个写入端, + // 互斥锁保证同一时刻只有一个线程在写。 writer: Arc::new(Mutex::new(writer)), child, size, @@ -45,54 +86,94 @@ impl PtySession { } /// 取出读取端,供 PTY 读取任务独占使用。取走后不可再次调用。 + /// + /// 为什么是"独占"而不是共享? + /// 1. 读取必须持续不断地进行,否则子进程输出一多就会阻塞在管道上; + /// 2. portable-pty 在 Windows 上要求读取由单一专用线程完成。 + /// 所以这里用 `Option::take` 把所有权移交出去(self.reader 变回 None), + /// 会话本身不再持有读取端,调用方拿到后负责持续读取并喂给终端引擎。 pub fn take_reader(&mut self) -> Option> { self.reader.take() } /// 获取共享写入端。 + /// + /// 返回的是 Arc 引用,调用方(一般是 WebSocket 处理层)可以克隆这个 + /// `Arc>` 到多个任务里,从而共享同一个写入端。 pub fn writer(&self) -> &Arc>> { &self.writer } - /// 将数据写入 Slave 端(发送给 Shell)。 + /// 将数据写入 Master 端(最终送达 Shell 的 stdin)。 + /// + /// 这是"客户端输入 → 服务端 → 子进程"这条链路的落点: + /// 任何客户端发来的键盘输入最终都会调用到这里。 pub fn write(&self, data: &[u8]) -> Result<()> { + // 先加锁,避免多个连接同时写造成数据交错/竞态。 let mut w = self .writer .lock() .map_err(|_| anyhow::anyhow!("pty writer poisoned"))?; + // write_all:要么全部写完,要么返回错误(内部会循环补完短写)。 w.write_all(data)?; + // flush:确保数据真正发到内核/管道,而不是留在用户态缓冲里。 w.flush()?; Ok(()) } /// 调整 PTY 尺寸(通知内核与子进程)。 + /// + /// 客户端窗口大小变化时调用:把新行列数同步给 PTY 内核对象, + /// 子进程会收到 SIGWINCH 信号(Unix)或等效通知,从而重新排版 + /// (例如 vim / htop 会立刻按新行列数重绘)。 pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> { + // 先更新本地缓存,便于随时查询当前尺寸。 self.size.rows = rows; self.size.cols = cols; + // 再真正执行 resize,通知内核与子进程。 self.master.resize(self.size).context("failed to resize pty") } - /// 当前 PTY 尺寸。 + /// 当前 PTY 尺寸,返回 (列, 行)。 pub fn size(&self) -> (u16, u16) { (self.size.cols, self.size.rows) } - /// 子进程是否已退出。 + /// 子进程是否已退出(非阻塞轮询)。 + /// + /// - `Ok(None)` 表示进程仍在运行; + /// - `Ok(Some(status))` 表示已退出,并拿到退出状态。 + /// 服务端后台任务定期调用它探测 shell 是否退出,用于清理会话。 pub fn try_wait(&mut self) -> Result> { self.child.try_wait().context("failed to poll child") } /// 终止子进程。 + /// + /// 会话关闭 / 客户端主动断开时调用,直接杀死 shell, + /// 避免留下孤儿进程继续驻留。 pub fn kill(&mut self) -> Result<()> { self.child.kill().context("failed to kill child") } } /// 拉起子进程,并把 Stdio 重定向到 PTY 的 Slave 端。 +/// +/// 关键点: +/// - `CommandBuilder` 是 portable_pty 对系统命令构造器的跨平台封装; +/// - `pair.slave.spawn_command` 会把子进程的 stdin/stdout/stderr +/// 全部接到 slave 端,这样 shell 的输入输出就都走 PTY 了。 fn spawn_child(pair: &PtyPair, shell: &str) -> Result> { 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"); + + // 真正拉起子进程。 let child = pair.slave.spawn_command(cmd)?; Ok(child) } diff --git a/src/ws/handler.rs b/src/ws/handler.rs index de9adcf..0bcf739 100644 --- a/src/ws/handler.rs +++ b/src/ws/handler.rs @@ -68,6 +68,8 @@ pub struct Session { pub control: Arc>>, /// 当前连接的客户端数。 pub clients: AtomicUsize, + /// 当前连接的移动端客户端数(>0 时才生成语义快照,避免无谓开销)。 + pub mobile_clients: AtomicUsize, } /// WebSocket 路由入口。 @@ -88,9 +90,19 @@ async fn handle_socket( let client_id = format!("client-{}", CLIENT_SEQ.fetch_add(1, Ordering::Relaxed)); let kind = ClientKind::from_params(¶ms); + // 最小鉴权:配置了 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 session = match get_or_create_session(&state, &requested) { - Ok(s) => s, + let (session, resumed) = match get_or_create_session(&state, &requested) { + Ok(pair) => pair, Err(e) => { let msg = ServerMessage::Error { message: format!("session error: {e}") }; 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 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() { return; } - // 移动端在连接时获取一次语义化快照作为初始状态(桌面端走原始 ANSI 流)。 + // 移动端在连接时获取一次语义化快照作为初始状态。 if kind == ClientKind::Mobile { let snap = session.engine.lock().unwrap().snapshot(); let init = ServerMessage::MobileSnapshot { data: snap }; @@ -129,6 +141,19 @@ async fn handle_socket( // 订阅输出。 let mut out_rx = session.output.subscribe(); 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 { tokio::select! { @@ -179,6 +204,9 @@ async fn handle_socket( } session.clients.fetch_sub(1, Ordering::SeqCst); + if kind == ClientKind::Mobile { + session.mobile_clients.fetch_sub(1, Ordering::SeqCst); + } // 若已无客户端,安排空闲超时清理(兜底机制,Windows ConPTY 下进程退出 // 检测不可靠,依赖超时确保会话最终被释放)。 @@ -227,7 +255,13 @@ fn handle_client_message( let mut pty = session.pty.lock().unwrap(); let _ = pty.resize(cols, rows); 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 } ClientMessage::ClaimControl => { @@ -240,7 +274,7 @@ fn handle_client_message( drop(control); 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( state: &Arc, requested: &str, -) -> Result> { - if !requested.is_empty() - && let Some(s) = state.sessions.get(requested) - { - return Ok(s.clone()); +) -> Result<(Arc, bool)> { + use dashmap::mapref::entry::Entry; + + // 指定了会话 ID:按 ID 原子地“查-建-插”。 + // - 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 session = create_session(id.clone(), &state.config, state.clone())?; - state.sessions.insert(id.clone(), session.clone()); - Ok(session) + match state.sessions.entry(id.clone()) { + Entry::Occupied(e) => Ok((e.get().clone(), true)), // 理论极罕见 + Entry::Vacant(v) => { + let session = create_session(id, &state.config, state.clone())?; + Ok((v.insert(session).clone(), false)) + } + } } /// 创建会话并启动 PTY 读取任务。 @@ -284,6 +333,7 @@ fn create_session( output, control: Arc::new(Mutex::new(None)), clients: AtomicUsize::new(0), + mobile_clients: AtomicUsize::new(0), }); start_pty_reader(session.clone(), reader, state); @@ -312,19 +362,24 @@ fn start_pty_reader( }; let chunk = &buf[..n]; - // 喂给解析器并生成快照。 - let snapshot = { - let mut eng = match session.engine.lock() { - Ok(e) => e, - Err(_) => break, - }; - eng.feed(chunk); - eng.snapshot() + // 引擎锁内完成“喂解析 + 发帧”:保证 Raw/快照帧与重绘帧严格有序 + // (桌面端重连重放依赖这一不变量)。 + let mut eng = match session.engine.lock() { + Ok(e) => e, + Err(_) => break, }; + eng.feed(chunk); - // 广播原始字节 + 快照。 + // 广播原始 ANSI 字节(桌面端渲染)。 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 读到 EOF(Unix 场景):触发会话结束并清理。 diff --git a/src/ws/protocol.rs b/src/ws/protocol.rs index f918fef..028ddf1 100644 --- a/src/ws/protocol.rs +++ b/src/ws/protocol.rs @@ -51,6 +51,8 @@ pub enum ServerMessage { id: String, cols: u16, rows: u16, + /// 是否恢复了已存在的会话(false 表示本次新建了会话)。 + resumed: bool, }, /// 移动端专属:解耦后的语义化屏显快照。 MobileSnapshot { @@ -61,6 +63,8 @@ pub enum ServerMessage { granted: bool, holder: Option, }, + /// 心跳应答。 + Pong, /// 服务端错误。 Error { message: String,