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