From 9608c9139c968776a7800112b741e62fb4ae1c83 Mon Sep 17 00:00:00 2001 From: lollipopkit Date: Sat, 28 Jan 2023 00:17:36 +0800 Subject: [PATCH] init `ssh` --- README.md | 9 +- lib/data/model/app/menu_item.dart | 3 +- lib/data/res/build_data.dart | 6 +- lib/view/page/server/tab.dart | 121 ++++++++++---------- lib/view/page/snippet/list.dart | 154 +++++++------------------- lib/view/page/ssh.dart | 100 +++++++++++++++++ lib/view/widget/virtual_keyboard.dart | 80 +++++++++++++ pubspec.lock | 32 ++++++ pubspec.yaml | 1 + 9 files changed, 325 insertions(+), 181 deletions(-) create mode 100644 lib/view/page/ssh.dart create mode 100644 lib/view/widget/virtual_keyboard.dart diff --git a/README.md b/README.md index 261c191b..ffca03db 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,16 @@

-A Flutter project which provide charts to display server status and tools to manage server.
Especially thanks to dartssh2. +A Flutter project which provide charts to display server status and tools to manage server. +
+Especially thanks to dartssh2 & xterm.dart.

-## 🔖 Milestone +## 🔖 Feature - [x] Status chart view -- [x] Snippet ~~market~~, Ping, SFTP, Docker, Apt/Yum and etc. +- [x] SSH terminal +- [x] `Docker Manage`, `Pkg Manage`, `SFTP`, `Snippet` ~~market~~, `Ping` and etc. - [x] i18n (English, Chinese) - [x] Desktop support diff --git a/lib/data/model/app/menu_item.dart b/lib/data/model/app/menu_item.dart index 40f2c7e0..70947bbb 100644 --- a/lib/data/model/app/menu_item.dart +++ b/lib/data/model/app/menu_item.dart @@ -24,11 +24,12 @@ class DropdownBtnItem { } class ServerTabMenuItems { - static const List firstItems = [sftp, pkg, docker]; + static const List firstItems = [sftp, snippet, pkg, docker]; static const List secondItems = [edit]; static const sftp = DropdownBtnItem(text: 'SFTP', icon: Icons.insert_drive_file); + static const snippet = DropdownBtnItem(text: 'Snippet', icon: Icons.code); static const pkg = DropdownBtnItem(text: 'Pkg', icon: Icons.system_security_update); static const docker = diff --git a/lib/data/res/build_data.dart b/lib/data/res/build_data.dart index 9ee10896..35b2e430 100644 --- a/lib/data/res/build_data.dart +++ b/lib/data/res/build_data.dart @@ -2,9 +2,9 @@ class BuildData { static const String name = "ServerBox"; - static const int build = 183; + static const int build = 186; static const String engine = "Flutter 3.7.0 • channel stable • https://github.com/flutter/flutter.git\nFramework • revision b06b8b2710 (4 days ago) • 2023-01-23 16:55:55 -0800\nEngine • revision b24591ed32\nTools • Dart 2.19.0 • DevTools 2.20.1\n"; - static const String buildAt = "2023-01-27 21:38:08.181334"; - static const int modifications = 19; + static const String buildAt = "2023-01-28 00:10:21.021365"; + static const int modifications = 8; } diff --git a/lib/view/page/server/tab.dart b/lib/view/page/server/tab.dart index d1058019..8a793a13 100644 --- a/lib/view/page/server/tab.dart +++ b/lib/view/page/server/tab.dart @@ -21,6 +21,7 @@ import 'package:toolbox/view/page/server/detail.dart'; import 'package:toolbox/view/page/server/edit.dart'; import 'package:toolbox/view/page/sftp/view.dart'; import 'package:toolbox/view/page/snippet/edit.dart'; +import 'package:toolbox/view/page/ssh.dart'; import 'package:toolbox/view/widget/picker.dart'; import 'package:toolbox/view/widget/round_rect_card.dart'; @@ -160,8 +161,8 @@ class _ServerPageState extends State context, _s.error, Text(ss.failedInfo ?? ''), []), child: Text(_s.clickSee, style: style)) : Text(topRightStr, style: style, textScaleFactor: 1.0), - const SizedBox(width: 7), - _buildSnippetBtn(spi), + const SizedBox(width: 9), + _buildSSHBtn(spi), _buildMoreBtn(spi), ], ) @@ -196,60 +197,13 @@ class _ServerPageState extends State ); } - Widget _buildSnippetBtn(ServerPrivateInfo spi) { + Widget _buildSSHBtn(ServerPrivateInfo spi) { return GestureDetector( - child: const Icon(Icons.play_arrow), - onTap: () { - final provider = locator(); - if (provider.snippets.isEmpty) { - showRoundDialog( - context, - _s.attention, - Text(_s.noSavedSnippet), - [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(_s.ok), - ), - TextButton( - onPressed: () => - AppRoute(const SnippetEditPage(), 'edit snippet') - .go(context), - child: Text(_s.addOne), - ) - ], - ); - return; - } - - var snippet = provider.snippets.first; - showRoundDialog( - context, - _s.choose, - buildPicker(provider.snippets.map((e) => Text(e.name)).toList(), - (idx) => snippet = provider.snippets[idx]), - [ - TextButton( - onPressed: () async { - Navigator.of(context).pop(); - final result = - await locator().runSnippet(spi.id, snippet); - showRoundDialog( - context, - _s.result, - Text(result ?? _s.error, style: textSize13), - [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(_s.ok)) - ], - ); - }, - child: Text(_s.run), - ) - ], - ); - }, + child: const Icon( + Icons.terminal, + size: 21, + ), + onTap: () => AppRoute(SSHPage(spi: spi), 'ssh page').go(context), ); } @@ -271,14 +225,16 @@ class _ServerPageState extends State ), ], onSelected: (value) { - final item = value as DropdownBtnItem; - switch (item) { + switch (value as DropdownBtnItem) { case ServerTabMenuItems.pkg: AppRoute(PkgManagePage(spi), 'pkg manage').go(context); break; case ServerTabMenuItems.sftp: AppRoute(SFTPPage(spi), 'SFTP').go(context); break; + case ServerTabMenuItems.snippet: + _showSnippetDialog(spi.id); + break; case ServerTabMenuItems.edit: AppRoute(ServerEditPage(spi: spi), 'Edit server info').go(context); break; @@ -393,6 +349,57 @@ class _ServerPageState extends State ); } + void _showSnippetDialog(String id) { + final provider = locator(); + if (provider.snippets.isEmpty) { + showRoundDialog( + context, + _s.attention, + Text(_s.noSavedSnippet), + [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(_s.ok), + ), + TextButton( + onPressed: () => + AppRoute(const SnippetEditPage(), 'edit snippet').go(context), + child: Text(_s.addOne), + ) + ], + ); + return; + } + + var snippet = provider.snippets.first; + showRoundDialog( + context, + _s.choose, + buildPicker(provider.snippets.map((e) => Text(e.name)).toList(), + (idx) => snippet = provider.snippets[idx]), + [ + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + final result = + await locator().runSnippet(id, snippet); + showRoundDialog( + context, + _s.result, + Text(result ?? _s.error, style: textSize13), + [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(_s.ok)) + ], + ); + }, + child: Text(_s.run), + ) + ], + ); + } + @override bool get wantKeepAlive => true; diff --git a/lib/view/page/snippet/list.dart b/lib/view/page/snippet/list.dart index f41aba1c..2e937ee1 100644 --- a/lib/view/page/snippet/list.dart +++ b/lib/view/page/snippet/list.dart @@ -1,31 +1,22 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:toolbox/core/route.dart'; -import 'package:toolbox/core/utils.dart'; -import 'package:toolbox/data/model/server/server_private_info.dart'; -import 'package:toolbox/data/model/server/snippet.dart'; -import 'package:toolbox/data/provider/server.dart'; import 'package:toolbox/data/provider/snippet.dart'; import 'package:toolbox/data/res/color.dart'; import 'package:toolbox/data/res/font_style.dart'; import 'package:toolbox/data/res/padding.dart'; import 'package:toolbox/generated/l10n.dart'; -import 'package:toolbox/locator.dart'; import 'package:toolbox/view/page/snippet/edit.dart'; -import 'package:toolbox/view/widget/picker.dart'; import 'package:toolbox/view/widget/round_rect_card.dart'; class SnippetListPage extends StatefulWidget { - const SnippetListPage({Key? key, this.spi}) : super(key: key); - final ServerPrivateInfo? spi; + const SnippetListPage({Key? key}) : super(key: key); @override _SnippetListPageState createState() => _SnippetListPageState(); } class _SnippetListPageState extends State { - late ServerPrivateInfo _selectedSpi; - final _textStyle = TextStyle(color: primaryColor); late S _s; @@ -54,116 +45,45 @@ class _SnippetListPageState extends State { Widget _buildBody() { return Consumer( builder: (_, key, __) { - return key.snippets.isNotEmpty - ? ListView.builder( - padding: const EdgeInsets.all(13), - itemCount: key.snippets.length, - itemExtent: 57, - itemBuilder: (context, idx) { - return RoundRectCard( - Padding( - padding: roundRectCardPadding, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - key.snippets[idx].name, - textAlign: TextAlign.center, - ), - Row( - children: [ - TextButton( - onPressed: () => AppRoute( - SnippetEditPage( - snippet: key.snippets[idx]), - 'snippet edit page') - .go(context), - child: Text( - _s.edit, - style: _textStyle, - ), - ), - TextButton( - onPressed: () { - final snippet = key.snippets[idx]; - if (widget.spi == null) { - _showRunDialog(snippet); - return; - } - run(context, snippet); - }, - child: Text( - _s.run, - style: _textStyle, - ), - ) - ], - ) - ], + if (key.snippets.isEmpty) { + return Center( + child: Text(_s.noSavedSnippet), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(13), + itemCount: key.snippets.length, + itemExtent: 57, + itemBuilder: (context, idx) { + return RoundRectCard( + Padding( + padding: roundRectCardPadding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + key.snippets[idx].name, + textAlign: TextAlign.center, + ), + TextButton( + onPressed: () => AppRoute( + SnippetEditPage(snippet: key.snippets[idx]), + 'snippet edit page') + .go(context), + child: Text( + _s.edit, + style: _textStyle, ), ), - ); - }, - ) - : Center( - child: Text(_s.noSavedSnippet), - ); + ], + ), + ), + ); + }, + ); }, ); } - - void _showRunDialog(Snippet snippet) { - showRoundDialog( - context, - _s.chooseDestination, - Consumer( - builder: (_, provider, __) { - if (provider.servers.isEmpty) { - return Text(_s.noServerAvailable); - } - _selectedSpi = provider.servers.first.info; - return buildPicker( - provider.servers - .map((e) => Text( - e.info.name, - textAlign: TextAlign.center, - )) - .toList(), - (idx) => _selectedSpi = provider.servers[idx].info); - }, - ), - [ - TextButton( - onPressed: () async { - Navigator.of(context).pop(); - run(context, snippet); - }, - child: Text(_s.run), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(_s.cancel), - ), - ], - ); - } - - Future run(BuildContext context, Snippet snippet) async { - final id = (widget.spi ?? _selectedSpi).id; - final result = await locator().runSnippet(id, snippet); - if (result != null) { - showRoundDialog( - context, - _s.result, - Text(result, style: const TextStyle(fontSize: 13)), - [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(_s.close), - ) - ], - ); - } - } } diff --git a/lib/view/page/ssh.dart b/lib/view/page/ssh.dart new file mode 100644 index 00000000..be401def --- /dev/null +++ b/lib/view/page/ssh.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:xterm/xterm.dart'; + +import '../../data/model/server/server_private_info.dart'; +import '../../data/provider/server.dart'; +import '../../locator.dart'; +import '../widget/virtual_keyboard.dart'; + +class SSHPage extends StatefulWidget { + final ServerPrivateInfo spi; + const SSHPage({Key? key, required this.spi}) : super(key: key); + + @override + _SSHPageState createState() => _SSHPageState(); +} + +class _SSHPageState extends State { + late final terminal = Terminal(inputHandler: keyboard); + + final keyboard = VirtualKeyboard(defaultInputHandler); + + var title = ''; + + @override + void initState() { + super.initState(); + initTerminal(); + } + + Future initTerminal() async { + terminal.write('Connecting...\r\n'); + + final client = locator() + .servers + .where((e) => e.info.id == widget.spi.id) + .first + .client; + if (client == null) { + terminal.write('Failed to connect\r\n'); + return; + } + + terminal.write('Connected\r\n'); + + final session = await client.shell( + pty: SSHPtyConfig( + width: terminal.viewWidth, + height: terminal.viewHeight, + ), + ); + + terminal.buffer.clear(); + terminal.buffer.setCursor(0, 0); + + terminal.onTitleChange = (title) { + setState(() => this.title = title); + }; + + terminal.onResize = (width, height, pixelWidth, pixelHeight) { + session.resizeTerminal(width, height, pixelWidth, pixelHeight); + }; + + terminal.onOutput = (data) { + session.write(utf8.encode(data) as Uint8List); + }; + + session.stdout + .cast>() + .transform(const Utf8Decoder()) + .listen(terminal.write); + + session.stderr + .cast>() + .transform(const Utf8Decoder()) + .listen(terminal.write); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(title), + backgroundColor: + Theme.of(context).appBarTheme.backgroundColor?.withOpacity(0.5), + ), + body: Column( + children: [ + Expanded( + child: TerminalView(terminal, keyboardType: TextInputType.none), + ), + VirtualKeyboardView(keyboard), + ], + ), + ); + } +} diff --git a/lib/view/widget/virtual_keyboard.dart b/lib/view/widget/virtual_keyboard.dart new file mode 100644 index 00000000..5625e2c7 --- /dev/null +++ b/lib/view/widget/virtual_keyboard.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:xterm/xterm.dart'; + +class VirtualKeyboardView extends StatelessWidget { + const VirtualKeyboardView(this.keyboard, {super.key}); + + final VirtualKeyboard keyboard; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: keyboard, + builder: (context, child) => ToggleButtons( + isSelected: [keyboard.ctrl, keyboard.alt, keyboard.shift], + onPressed: (index) { + switch (index) { + case 0: + keyboard.ctrl = !keyboard.ctrl; + break; + case 1: + keyboard.alt = !keyboard.alt; + break; + case 2: + keyboard.shift = !keyboard.shift; + break; + } + }, + children: const [Text('Ctrl'), Text('Alt'), Text('Shift')], + ), + ); + } +} + +class VirtualKeyboard extends TerminalInputHandler with ChangeNotifier { + final TerminalInputHandler _inputHandler; + + VirtualKeyboard(this._inputHandler); + + bool _ctrl = false; + + bool get ctrl => _ctrl; + + set ctrl(bool value) { + if (_ctrl != value) { + _ctrl = value; + notifyListeners(); + } + } + + bool _shift = false; + + bool get shift => _shift; + + set shift(bool value) { + if (_shift != value) { + _shift = value; + notifyListeners(); + } + } + + bool _alt = false; + + bool get alt => _alt; + + set alt(bool value) { + if (_alt != value) { + _alt = value; + notifyListeners(); + } + } + + @override + String? call(TerminalKeyboardEvent event) { + return _inputHandler.call(event.copyWith( + ctrl: event.ctrl || _ctrl, + shift: event.shift || _shift, + alt: event.alt || _alt, + )); + } +} diff --git a/pubspec.lock b/pubspec.lock index 33e16e25..945cf126 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -250,6 +250,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + equatable: + dependency: transitive + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" extended_image: dependency: "direct main" description: @@ -638,6 +646,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + platform_info: + dependency: transitive + description: + name: platform_info + sha256: "012e73712166cf0b56d3eb95c0d33491f56b428c169eca385f036448474147e4" + url: "https://pub.dev" + source: hosted + version: "3.2.0" plugin_platform_interface: dependency: transitive description: @@ -694,6 +710,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + quiver: + dependency: transitive + description: + name: quiver + sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47 + url: "https://pub.dev" + source: hosted + version: "3.2.1" r_upgrade: dependency: "direct main" description: @@ -963,6 +987,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.2.2" + xterm: + dependency: "direct main" + description: + name: xterm + sha256: f65619cb24d03507812e346ddb8386cad9e16a01a481a8f5c8a2eba55b4edada + url: "https://pub.dev" + source: hosted + version: "3.4.1" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c129065c..b76f2a5b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: share_plus: ^6.3.0 intl: ^0.17.0 share_plus_web: ^3.1.0 + xterm: ^3.4.1 dev_dependencies: flutter_native_splash: ^2.1.6