feat. & fix.

- support backup & restore
- fix when client.run empty return
This commit is contained in:
Junyuan Feng
2022-05-22 13:02:54 +08:00
parent 228f228d44
commit 0fdc1b784b
10 changed files with 386 additions and 127 deletions

View File

@@ -0,0 +1,52 @@
import 'package:toolbox/data/model/server/private_key_info.dart';
import 'package:toolbox/data/model/server/server_private_info.dart';
import 'package:toolbox/data/model/server/snippet.dart';
class Backup {
// backup format version
final int version;
final String date;
final List<ServerPrivateInfo> spis;
final List<Snippet> snippets;
final List<PrivateKeyInfo> keys;
final int primaryColor;
final int serverStatusUpdateInterval;
final int launchPage;
Backup(
this.version,
this.date,
this.spis,
this.snippets,
this.keys,
this.primaryColor,
this.serverStatusUpdateInterval,
this.launchPage,
);
Backup.fromJson(Map<String, dynamic> json)
: version = json['version'] as int,
date = json['date'],
spis = (json['spis'] as List)
.map((e) => ServerPrivateInfo.fromJson(e))
.toList(),
snippets =
(json['snippets'] as List).map((e) => Snippet.fromJson(e)).toList(),
keys = (json['keys'] as List)
.map((e) => PrivateKeyInfo.fromJson(e))
.toList(),
primaryColor = json['primaryColor'],
serverStatusUpdateInterval = json['serverStatusUpdateInterval'],
launchPage = json['launchPage'];
Map<String, dynamic> toJson() => {
'version': version,
'date': date,
'spis': spis,
'snippets': snippets,
'keys': keys,
'primaryColor': primaryColor,
'serverStatusUpdateInterval': serverStatusUpdateInterval,
'launchPage': launchPage,
};
}

View File

@@ -193,6 +193,12 @@ class ServerProvider extends BusyProvider {
final si = _servers[idx];
if (si.client == null) return;
final raw = await si.client!.run("sh $shellPath").string;
if (raw.isEmpty) {
_servers[idx].connectionState = ServerConnectionState.failed;
_servers[idx].status.failedInfo = 'Empty output';
notifyListeners();
return;
}
final lines = raw.split(seperator).map((e) => e.trim()).toList();
lines.removeAt(0);

View File

@@ -43,17 +43,19 @@ class MessageLookup extends MessageLookupByLibrary {
static String m9(url) => "Please report bugs on ${url}";
static String m10(time) => "Spent time: ${time}";
static String m10(date) => "Are you sure to restore from ${date} ?";
static String m11(name) => "Are you sure to delete [${name}]?";
static String m11(time) => "Spent time: ${time}";
static String m12(server) => "Are you sure to delete server [${server}]?";
static String m12(name) => "Are you sure to delete [${name}]?";
static String m13(build) => "Found: v1.0.${build}, click to update";
static String m13(server) => "Are you sure to delete server [${server}]?";
static String m14(build) => "Current: v1.0.${build}";
static String m14(build) => "Found: v1.0.${build}, click to update";
static String m15(build) => "Current: v1.0.${build}, is up to date";
static String m15(build) => "Current: v1.0.${build}";
static String m16(build) => "Current: v1.0.${build}, is up to date";
final messages = _notInlinedMessages(_notInlinedMessages);
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
@@ -67,6 +69,11 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("App primary color"),
"attention": MessageLookupByLibrary.simpleMessage("Attention"),
"backDir": MessageLookupByLibrary.simpleMessage("Back"),
"backup": MessageLookupByLibrary.simpleMessage("Backup"),
"backupTip": MessageLookupByLibrary.simpleMessage(
"The exported data is simply encrypted. \nPlease keep it safe.\nRestoring will not overwrite existing data (except setting)."),
"backupVersionNotMatch": MessageLookupByLibrary.simpleMessage(
"Backup version is not match."),
"cancel": MessageLookupByLibrary.simpleMessage("Cancel"),
"choose": MessageLookupByLibrary.simpleMessage("Choose"),
"chooseDestination":
@@ -116,6 +123,7 @@ class MessageLookup extends MessageLookupByLibrary {
"install": MessageLookupByLibrary.simpleMessage("install"),
"installDockerWithUrl": MessageLookupByLibrary.simpleMessage(
"Please https://docs.docker.com/engine/install docker first."),
"invalidJson": MessageLookupByLibrary.simpleMessage("Invalid JSON"),
"invalidVersionHelp": m7,
"keepForeground":
MessageLookupByLibrary.simpleMessage("Keep app foreground!"),
@@ -163,6 +171,9 @@ class MessageLookup extends MessageLookupByLibrary {
"pwd": MessageLookupByLibrary.simpleMessage("Password"),
"rename": MessageLookupByLibrary.simpleMessage("Rename"),
"reportBugsOnGithubIssue": m9,
"restoreSuccess": MessageLookupByLibrary.simpleMessage(
"Restore success. Restart app to apply."),
"restoreSureWithDate": m10,
"result": MessageLookupByLibrary.simpleMessage("Result"),
"run": MessageLookupByLibrary.simpleMessage("Run"),
"save": MessageLookupByLibrary.simpleMessage("Save"),
@@ -186,11 +197,11 @@ class MessageLookup extends MessageLookupByLibrary {
"sftpSSHConnected":
MessageLookupByLibrary.simpleMessage("SFTP Connected"),
"snippet": MessageLookupByLibrary.simpleMessage("Snippet"),
"spentTime": m10,
"spentTime": m11,
"start": MessageLookupByLibrary.simpleMessage("Start"),
"stop": MessageLookupByLibrary.simpleMessage("Stop"),
"sureDelete": m11,
"sureToDeleteServer": m12,
"sureDelete": m12,
"sureToDeleteServer": m13,
"ttl": MessageLookupByLibrary.simpleMessage("TTL"),
"unknown": MessageLookupByLibrary.simpleMessage("unknown"),
"unknownError": MessageLookupByLibrary.simpleMessage("Unknown error"),
@@ -204,9 +215,9 @@ class MessageLookup extends MessageLookupByLibrary {
"upsideDown": MessageLookupByLibrary.simpleMessage("Upside Down"),
"urlOrJson": MessageLookupByLibrary.simpleMessage("URL or JSON"),
"user": MessageLookupByLibrary.simpleMessage("User"),
"versionHaveUpdate": m13,
"versionUnknownUpdate": m14,
"versionUpdated": m15,
"versionHaveUpdate": m14,
"versionUnknownUpdate": m15,
"versionUpdated": m16,
"waitConnection": MessageLookupByLibrary.simpleMessage(
"Please wait for the connection to be established."),
"willTakEeffectImmediately":

View File

@@ -43,17 +43,19 @@ class MessageLookup extends MessageLookupByLibrary {
static String m9(url) => "请到 ${url} 提交问题";
static String m10(time) => "耗时: ${time}";
static String m10(date) => "确定恢复 ${date} 的备份吗?";
static String m11(name) => "确定删除[${name}]";
static String m11(time) => "耗时: ${time}";
static String m12(server) => "确定删除服务器 [${server}]";
static String m12(name) => "确定删除[${name}]";
static String m13(build) => "找到新版本v1.0.${build}, 点击更新";
static String m13(server) => "你确定要删除服务器 [${server}] 吗?";
static String m14(build) => "当前v1.0.${build}";
static String m14(build) => "找到新版本v1.0.${build}, 点击更新";
static String m15(build) => "当前v1.0.${build}, 已是最新版本";
static String m15(build) => "当前v1.0.${build}";
static String m16(build) => "当前v1.0.${build}, 已是最新版本";
final messages = _notInlinedMessages(_notInlinedMessages);
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
@@ -64,6 +66,11 @@ class MessageLookup extends MessageLookupByLibrary {
"appPrimaryColor": MessageLookupByLibrary.simpleMessage("App主要色"),
"attention": MessageLookupByLibrary.simpleMessage("注意"),
"backDir": MessageLookupByLibrary.simpleMessage("返回上一级"),
"backup": MessageLookupByLibrary.simpleMessage("备份"),
"backupTip": MessageLookupByLibrary.simpleMessage(
"导出的数据仅进行了简单加密,请妥善保管。\n恢复的数据(除了设置)不会覆盖现有数据。"),
"backupVersionNotMatch":
MessageLookupByLibrary.simpleMessage("备份版本不匹配,无法恢复"),
"cancel": MessageLookupByLibrary.simpleMessage("取消"),
"choose": MessageLookupByLibrary.simpleMessage("选择"),
"chooseDestination": MessageLookupByLibrary.simpleMessage("选择目标"),
@@ -105,6 +112,7 @@ class MessageLookup extends MessageLookupByLibrary {
"install": MessageLookupByLibrary.simpleMessage("安装"),
"installDockerWithUrl": MessageLookupByLibrary.simpleMessage(
"请先 https://docs.docker.com/engine/install docker"),
"invalidJson": MessageLookupByLibrary.simpleMessage("无效的json存在格式问题"),
"invalidVersionHelp": m7,
"keepForeground": MessageLookupByLibrary.simpleMessage("请保持应用处于前台!"),
"keyAuth": MessageLookupByLibrary.simpleMessage("公钥认证"),
@@ -142,6 +150,9 @@ class MessageLookup extends MessageLookupByLibrary {
"pwd": MessageLookupByLibrary.simpleMessage("密码"),
"rename": MessageLookupByLibrary.simpleMessage("重命名"),
"reportBugsOnGithubIssue": m9,
"restoreSuccess":
MessageLookupByLibrary.simpleMessage("恢复成功需要重启App来应用更改"),
"restoreSureWithDate": m10,
"result": MessageLookupByLibrary.simpleMessage("结果"),
"run": MessageLookupByLibrary.simpleMessage("运行"),
"save": MessageLookupByLibrary.simpleMessage("保存"),
@@ -160,11 +171,11 @@ class MessageLookup extends MessageLookupByLibrary {
"sftpSSHConnected":
MessageLookupByLibrary.simpleMessage("SFTP 已连接,即将开始下载..."),
"snippet": MessageLookupByLibrary.simpleMessage("代码片段"),
"spentTime": m10,
"spentTime": m11,
"start": MessageLookupByLibrary.simpleMessage("开始"),
"stop": MessageLookupByLibrary.simpleMessage("停止"),
"sureDelete": m11,
"sureToDeleteServer": m12,
"sureDelete": m12,
"sureToDeleteServer": m13,
"ttl": MessageLookupByLibrary.simpleMessage("缓存时间"),
"unknown": MessageLookupByLibrary.simpleMessage("未知"),
"unknownError": MessageLookupByLibrary.simpleMessage("未知错误"),
@@ -177,9 +188,9 @@ class MessageLookup extends MessageLookupByLibrary {
"upsideDown": MessageLookupByLibrary.simpleMessage("上下交换"),
"urlOrJson": MessageLookupByLibrary.simpleMessage("链接或JSON"),
"user": MessageLookupByLibrary.simpleMessage("用户"),
"versionHaveUpdate": m13,
"versionUnknownUpdate": m14,
"versionUpdated": m15,
"versionHaveUpdate": m14,
"versionUnknownUpdate": m15,
"versionUpdated": m16,
"waitConnection": MessageLookupByLibrary.simpleMessage("请等待连接建立"),
"willTakEeffectImmediately":
MessageLookupByLibrary.simpleMessage("更改将会立即生效")

View File

@@ -1230,6 +1230,66 @@ class S {
args: [],
);
}
/// `The exported data is simply encrypted. \nPlease keep it safe.\nRestoring will not overwrite existing data (except setting).`
String get backupTip {
return Intl.message(
'The exported data is simply encrypted. \nPlease keep it safe.\nRestoring will not overwrite existing data (except setting).',
name: 'backupTip',
desc: '',
args: [],
);
}
/// `Backup`
String get backup {
return Intl.message(
'Backup',
name: 'backup',
desc: '',
args: [],
);
}
/// `Are you sure to restore from {date} ?`
String restoreSureWithDate(Object date) {
return Intl.message(
'Are you sure to restore from $date ?',
name: 'restoreSureWithDate',
desc: '',
args: [date],
);
}
/// `Backup version is not match.`
String get backupVersionNotMatch {
return Intl.message(
'Backup version is not match.',
name: 'backupVersionNotMatch',
desc: '',
args: [],
);
}
/// `Invalid JSON`
String get invalidJson {
return Intl.message(
'Invalid JSON',
name: 'invalidJson',
desc: '',
args: [],
);
}
/// `Restore success. Restart app to apply.`
String get restoreSuccess {
return Intl.message(
'Restore success. Restart app to apply.',
name: 'restoreSuccess',
desc: '',
args: [],
);
}
}
class AppLocalizationDelegate extends LocalizationsDelegate<S> {

View File

@@ -116,5 +116,11 @@
"invalidVersionHelp": "Please make sure that docker is installed correctly, or that you are using a non-self-compiled version. If you don't have the above issues, please submit an issue on {url}.",
"noInterface": "No interface",
"lastTry": "Last try!",
"pingNoServer": "No server to ping.\nPlease add a server in server tab."
"pingNoServer": "No server to ping.\nPlease add a server in server tab.",
"backupTip": "The exported data is simply encrypted. \nPlease keep it safe.\nRestoring will not overwrite existing data (except setting).",
"backup": "Backup",
"restoreSureWithDate": "Are you sure to restore from {date} ?",
"backupVersionNotMatch": "Backup version is not match.",
"invalidJson": "Invalid JSON",
"restoreSuccess": "Restore success. Restart app to apply."
}

View File

@@ -116,5 +116,11 @@
"invalidVersionHelp": "请确保正确安装了docker或者使用的非自编译版本。如果没有以上问题请在 {url} 提交问题。",
"noInterface": "没有可用的接口",
"lastTry": "最后尝试",
"pingNoServer": "没有服务器可用于Ping\n请在服务器tab添加服务器后再试"
"pingNoServer": "没有服务器可用于Ping\n请在服务器tab添加服务器后再试",
"backupTip": "导出的数据仅进行了简单加密,请妥善保管。\n恢复的数据除了设置不会覆盖现有数据。",
"backup": "备份",
"restoreSureWithDate": "确定恢复 {date} 的备份吗?",
"backupVersionNotMatch": "备份版本不匹配,无法恢复",
"invalidJson": "无效的json存在格式问题",
"restoreSuccess": "恢复成功需要重启App来应用更改"
}

201
lib/view/page/backup.dart Normal file
View File

@@ -0,0 +1,201 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:toolbox/core/utils.dart';
import 'package:toolbox/data/model/app/backup.dart';
import 'package:toolbox/data/res/font_style.dart';
import 'package:toolbox/data/store/private_key.dart';
import 'package:toolbox/data/store/server.dart';
import 'package:toolbox/data/store/setting.dart';
import 'package:toolbox/data/store/snippet.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/locator.dart';
import 'package:toolbox/view/widget/round_rect_card.dart';
const backupFormatVersion = 1;
class BackupPage extends StatelessWidget {
BackupPage({Key? key}) : super(key: key);
final setting = locator<SettingStore>();
final server = locator<ServerStore>();
final snippet = locator<SnippetStore>();
final privateKey = locator<PrivateKeyStore>();
@override
Widget build(BuildContext context) {
final media = MediaQuery.of(context);
final s = S.of(context);
return Scaffold(
appBar: AppBar(
title: Text(s.importAndExport, style: size18),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(37),
child: Text(
s.backupTip,
textAlign: TextAlign.center,
),
),
const SizedBox(
height: 77,
),
_buildCard(s.import, Icons.download, media,
() => _showImportDialog(context, s)),
_buildCard(s.export, Icons.file_upload, media,
() => _showExportDialog(context, s))
],
)),
);
}
Widget _buildCard(String text, IconData icon, MediaQueryData media,
FutureOr Function() onTap) {
return RoundRectCard(InkWell(
onTap: onTap,
child: SizedBox(
width: media.size.width * 0.77,
height: media.size.height * 0.17,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: media.size.height * 0.05),
const SizedBox(height: 10),
Text(text, style: TextStyle(fontSize: media.size.height * 0.02)),
],
),
)));
}
Future<void> _showExportDialog(BuildContext context, S s) async {
final exportFieldController = TextEditingController()
..text = _diyEncrtpt(json.encode(Backup(
backupFormatVersion,
DateTime.now().toString().split('.').first,
server.fetch(),
snippet.fetch(),
privateKey.fetch(),
setting.primaryColor.fetch() ?? Colors.pinkAccent.value,
setting.serverStatusUpdateInterval.fetch() ?? 2,
setting.launchPage.fetch() ?? 0,
)));
await showRoundDialog(
context,
s.export,
TextField(
decoration: const InputDecoration(
labelText: 'JSON',
),
maxLines: 7,
controller: exportFieldController,
),
[
TextButton(
child: Text(s.copy),
onPressed: () {
Clipboard.setData(
ClipboardData(text: exportFieldController.text));
Navigator.pop(context);
},
),
]);
}
Future<void> _showImportDialog(BuildContext context, S s) async {
final importFieldController = TextEditingController();
await showRoundDialog(
context,
s.import,
TextField(
decoration: const InputDecoration(
labelText: 'JSON',
),
maxLines: 3,
controller: importFieldController,
),
[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.cancel)),
TextButton(
onPressed: () async =>
await _import(importFieldController.text.trim(), context, s),
child: const Text('GO'),
)
]);
}
Future<void> _import(String text, BuildContext context, S s) async {
if (text.isEmpty) {
showSnackBar(context, Text(s.fieldMustNotEmpty));
return;
}
_importBackup(text, context, s);
Navigator.of(context).pop();
}
Future<void> _importBackup(String raw, BuildContext context, S s) async {
try {
final backup = await compute(_decode, raw);
if (backupFormatVersion != backup.version) {
showSnackBar(context, Text(s.backupVersionNotMatch));
return;
}
await showRoundDialog(
context, s.attention, Text(s.restoreSureWithDate(backup.date)), [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.cancel),
),
TextButton(
onPressed: () async {
for (final s in backup.snippets) {
snippet.put(s);
}
for (final s in backup.spis) {
server.put(s);
}
for (final s in backup.keys) {
privateKey.put(s);
}
setting.primaryColor.put(backup.primaryColor);
setting.serverStatusUpdateInterval
.put(backup.serverStatusUpdateInterval);
setting.launchPage.put(backup.launchPage);
Navigator.of(context).pop();
showSnackBar(context, Text(s.restoreSuccess));
},
child: Text(s.ok),
),
]);
} catch (e) {
showSnackBar(context, Text(s.invalidJson));
return;
}
}
}
Backup _decode(String raw) {
final decrypted = _diyDecrypt(raw);
return Backup.fromJson(json.decode(decrypted));
}
String _diyEncrtpt(String raw) =>
json.encode(raw.codeUnits.map((e) => e * 2 + 1).toList(growable: false));
String _diyDecrypt(String raw) {
final list = json.decode(raw);
final sb = StringBuffer();
for (final e in list) {
sb.writeCharCode((e - 1) ~/ 2);
}
return sb.toString();
}

View File

@@ -18,6 +18,7 @@ import 'package:toolbox/data/res/url.dart';
import 'package:toolbox/data/store/setting.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/locator.dart';
import 'package:toolbox/view/page/backup.dart';
import 'package:toolbox/view/page/convert.dart';
import 'package:toolbox/view/page/debug.dart';
import 'package:toolbox/view/page/ping.dart';
@@ -240,6 +241,12 @@ class _MyHomePageState extends State<MyHomePage>
AppRoute(const SFTPDownloadedPage(), 'snippet list')
.go(context),
),
ListTile(
leading: const Icon(Icons.import_export),
title: Text(s.backup),
onTap: () =>
AppRoute(BackupPage(), 'backup page').go(context),
),
ListTile(
leading: const Icon(Icons.snippet_folder),
title: Text(s.snippet),

View File

@@ -1,4 +1,3 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:toolbox/core/route.dart';
@@ -26,9 +25,6 @@ class SnippetListPage extends StatefulWidget {
class _SnippetListPageState extends State<SnippetListPage> {
late ServerPrivateInfo _selectedIndex;
final _importFieldController = TextEditingController();
final _exportFieldController = TextEditingController();
final _textStyle = TextStyle(color: primaryColor);
late S s;
@@ -44,12 +40,6 @@ class _SnippetListPageState extends State<SnippetListPage> {
return Scaffold(
appBar: AppBar(
title: Text(s.snippet, style: size18),
actions: [
IconButton(
onPressed: () => _showImportExport(),
tooltip: s.importAndExport,
icon: const Icon(Icons.import_export)),
],
),
body: _buildBody(),
floatingActionButton: FloatingActionButton(
@@ -60,97 +50,6 @@ class _SnippetListPageState extends State<SnippetListPage> {
);
}
Future<void> _showImportExport() async {
await showRoundDialog(
context,
s.choose,
Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(s.import),
leading: const Icon(Icons.download),
onTap: () => _showImportDialog(),
),
ListTile(
title: Text(s.export),
leading: const Icon(Icons.file_upload),
onTap: () => _showExportDialog(),
),
],
),
[]);
}
Future<void> _showExportDialog() async {
Navigator.of(context).pop();
_exportFieldController.text = locator<SnippetProvider>().export;
await showRoundDialog(
context,
s.export,
TextField(
decoration: const InputDecoration(
labelText: 'JSON',
),
maxLines: 3,
controller: _exportFieldController,
),
[
TextButton(
child: Text(s.ok),
onPressed: () => Navigator.pop(context),
),
]);
}
Future<void> _showImportDialog() async {
Navigator.of(context).pop();
await showRoundDialog(
context,
s.import,
TextField(
decoration: InputDecoration(
labelText: s.urlOrJson,
),
maxLines: 2,
controller: _importFieldController,
),
[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.cancel)),
TextButton(
onPressed: () async =>
await _import(_importFieldController.text.trim()),
child: const Text('GO'),
)
]);
}
Future<void> _import(String text) async {
if (text.isEmpty) {
showSnackBar(context, Text(s.fieldMustNotEmpty));
return;
}
final snippetProvider = locator<SnippetProvider>();
if (text.startsWith('http')) {
final resp = await Dio().get(text);
if (resp.statusCode != 200) {
showSnackBar(
context, Text(s.httpFailedWithCode(resp.statusCode ?? '-1')));
return;
}
for (final snippet in getSnippetList(resp.data)) {
snippetProvider.add(snippet);
}
} else {
for (final snippet in getSnippetList(text)) {
snippetProvider.add(snippet);
}
}
Navigator.of(context).pop();
}
Widget _buildBody() {
return Consumer<SnippetProvider>(
builder: (_, key, __) {