Compare commits

...

26 Commits

Author SHA1 Message Date
lollipopkit
9cdfd86f70 replace: context.pop 2023-04-19 20:58:29 +08:00
lollipopkit
e709262477 fix: update changelog & backup restore 2023-04-19 20:52:02 +08:00
lollipopkit
20d2817c8d fix: moveBg during share 2023-04-19 20:18:54 +08:00
lollipopkit
a7269ee68a #25 fix sftp rm 2023-04-19 20:18:27 +08:00
lollipopkit
552e2738cd opt. for backup 2023-04-19 10:51:10 +08:00
lollipopkit
216d64e3eb new: license dialog 2023-04-18 22:37:19 +08:00
lollipopkit
f903a1da55 update: update api 2023-04-18 22:33:56 +08:00
lollipopkit
f2b1158eda update build script 2023-04-18 21:47:42 +08:00
lollipopkit
1d797f8d16 flutter 3.7.11 2023-04-17 13:09:59 +08:00
lollipopkit
9e503e4901 readd: paste in ssh 2023-04-17 12:55:47 +08:00
lollipopkit
bf22309fe5 opt. for performance 2023-04-16 19:41:54 +08:00
lollipopkit
a3537a67c2 #25 fix sftp rmdir 2023-04-16 16:26:50 +08:00
lollipopkit
8ba888a5b3 #25 snippet in SSH 2023-04-16 14:08:10 +08:00
lollipopkit
fd2d59b1e0 fix: backup restore failed 2023-04-16 11:59:00 +08:00
lollipopkit
5c2cc5e5e4 fix: Android moveBg during picking file 2023-04-05 15:15:17 +08:00
lollipopkit
fe7ea04011 boost loading speed 2023-04-05 15:03:18 +08:00
lollipopkit
19baff5dde opt. & fix.
fix: setting page update item display issue.
fix: `bgRun` condition issue.
opt: `r_upgrade` use 0.3.8+2
2023-04-04 17:38:00 +08:00
lollipopkit
0ac6984576 #21 font setting only take effect to SSH 2023-04-04 17:35:37 +08:00
lollipopkit
de0eedb2cb xterm.dart use git 2023-03-28 17:36:06 +08:00
lollipopkit
30dddf462e #22 Add switch for run in bg 2023-03-28 17:35:49 +08:00
lollipopkit
abee470afc Android: support background process 2023-03-28 17:12:44 +08:00
lollipopkit
42d8505504 Update README.md 2023-03-27 21:25:20 +08:00
lollipopkit
3c481cf61c Update README.md 2023-03-27 21:16:16 +08:00
lollipopkit
55c96031f7 fix: ios debug: local network 2023-03-27 15:59:05 +08:00
lollipopkit
2ea061d324 backup add docker 2023-03-27 15:09:19 +08:00
lollipopkit
2747df64dd #19 fix & opt
- opt: android default dir move to external storage
- fix: err when font not exist
2023-03-27 13:56:17 +08:00
52 changed files with 926 additions and 500 deletions

View File

@@ -159,10 +159,16 @@ abstract class S {
/// **'Backup'** /// **'Backup'**
String get backup; String get backup;
/// No description provided for @backupAndRestore.
///
/// In en, this message translates to:
/// **'Backup and Restore'**
String get backupAndRestore;
/// No description provided for @backupTip. /// No description provided for @backupTip.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'The exported data is simply encrypted. \nPlease keep it safe.\nRestoring will not overwrite existing data (except setting).'** /// **'The exported data is simply encrypted. \nPlease keep it safe.'**
String get backupTip; String get backupTip;
/// No description provided for @backupVersionNotMatch. /// No description provided for @backupVersionNotMatch.
@@ -171,6 +177,12 @@ abstract class S {
/// **'Backup version is not match.'** /// **'Backup version is not match.'**
String get backupVersionNotMatch; String get backupVersionNotMatch;
/// No description provided for @bgRun.
///
/// In en, this message translates to:
/// **'Run in backgroud'**
String get bgRun;
/// No description provided for @cancel. /// No description provided for @cancel.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -507,12 +519,6 @@ abstract class S {
/// **'Import'** /// **'Import'**
String get import; String get import;
/// No description provided for @importAndExport.
///
/// In en, this message translates to:
/// **'Import and Export'**
String get importAndExport;
/// No description provided for @inputDomainHere. /// No description provided for @inputDomainHere.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -606,7 +612,7 @@ abstract class S {
/// No description provided for @madeWithLove. /// No description provided for @madeWithLove.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'\nMade with ❤️ by {myGithub}'** /// **'Made with ❤️ by {myGithub}'**
String madeWithLove(Object myGithub); String madeWithLove(Object myGithub);
/// No description provided for @max. /// No description provided for @max.
@@ -735,6 +741,12 @@ abstract class S {
/// **'Open'** /// **'Open'**
String get open; String get open;
/// No description provided for @paste.
///
/// In en, this message translates to:
/// **'Paste'**
String get paste;
/// No description provided for @path. /// No description provided for @path.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -999,6 +1011,12 @@ abstract class S {
/// **'Are you sure to delete [{name}]?'** /// **'Are you sure to delete [{name}]?'**
String sureDelete(Object name); String sureDelete(Object name);
/// No description provided for @sureDirEmpty.
///
/// In en, this message translates to:
/// **'Make sure dir is empty.'**
String get sureDirEmpty;
/// No description provided for @sureNoPwd. /// No description provided for @sureNoPwd.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View File

@@ -38,11 +38,17 @@ class SEn extends S {
String get backup => 'Backup'; String get backup => 'Backup';
@override @override
String get backupTip => 'The exported data is simply encrypted. \nPlease keep it safe.\nRestoring will not overwrite existing data (except setting).'; String get backupAndRestore => 'Backup and Restore';
@override
String get backupTip => 'The exported data is simply encrypted. \nPlease keep it safe.';
@override @override
String get backupVersionNotMatch => 'Backup version is not match.'; String get backupVersionNotMatch => 'Backup version is not match.';
@override
String get bgRun => 'Run in backgroud';
@override @override
String get cancel => 'Cancel'; String get cancel => 'Cancel';
@@ -229,9 +235,6 @@ class SEn extends S {
@override @override
String get import => 'Import'; String get import => 'Import';
@override
String get importAndExport => 'Import and Export';
@override @override
String get inputDomainHere => 'Input Domain here'; String get inputDomainHere => 'Input Domain here';
@@ -281,7 +284,7 @@ class SEn extends S {
@override @override
String madeWithLove(Object myGithub) { String madeWithLove(Object myGithub) {
return '\nMade with ❤️ by $myGithub'; return 'Made with ❤️ by $myGithub';
} }
@override @override
@@ -347,6 +350,9 @@ class SEn extends S {
@override @override
String get open => 'Open'; String get open => 'Open';
@override
String get paste => 'Paste';
@override @override
String get path => 'Path'; String get path => 'Path';
@@ -489,6 +495,9 @@ class SEn extends S {
return 'Are you sure to delete [$name]?'; return 'Are you sure to delete [$name]?';
} }
@override
String get sureDirEmpty => 'Make sure dir is empty.';
@override @override
String get sureNoPwd => 'Are you sure to use no password?'; String get sureNoPwd => 'Are you sure to use no password?';

View File

@@ -38,11 +38,17 @@ class SZh extends S {
String get backup => '备份'; String get backup => '备份';
@override @override
String get backupTip => '导出的数据仅进行了简单加密,请妥善保管。\n除了设置项,恢复的数据不会覆盖现有数据。'; String get backupAndRestore => '备份和恢复';
@override
String get backupTip => '导出的数据仅进行了简单加密,请妥善保管。';
@override @override
String get backupVersionNotMatch => '备份版本不匹配,无法恢复'; String get backupVersionNotMatch => '备份版本不匹配,无法恢复';
@override
String get bgRun => '后台运行';
@override @override
String get cancel => '取消'; String get cancel => '取消';
@@ -229,9 +235,6 @@ class SZh extends S {
@override @override
String get import => '导入'; String get import => '导入';
@override
String get importAndExport => '导入或导出';
@override @override
String get inputDomainHere => '在这里输入域名'; String get inputDomainHere => '在这里输入域名';
@@ -281,7 +284,7 @@ class SZh extends S {
@override @override
String madeWithLove(Object myGithub) { String madeWithLove(Object myGithub) {
return '\n用❤️制作 by $myGithub'; return '用❤️制作 by $myGithub';
} }
@override @override
@@ -347,6 +350,9 @@ class SZh extends S {
@override @override
String get open => '打开'; String get open => '打开';
@override
String get paste => '粘贴';
@override @override
String get path => '路径'; String get path => '路径';
@@ -489,6 +495,9 @@ class SZh extends S {
return '确定删除[$name]'; return '确定删除[$name]';
} }
@override
String get sureDirEmpty => '请确保文件夹为空';
@override @override
String get sureNoPwd => '确认使用无密码?'; String get sureNoPwd => '确认使用无密码?';

View File

@@ -34,14 +34,18 @@ Especially thanks to <a href="https://github.com/TerminalStudio/dartssh2">dartss
- [x] Status charts - [x] Status charts
- [x] `Ping` and etc. - [x] `Ping` and etc.
- [x] i18n (English, Chinese) - [x] i18n (English, Chinese)
- **Welcome contribution** :) - Welcome contribution :), [How to contribute?](#l10n-guide)
- [How to contribute?](#l10n-guide)
- [x] Desktop support - [x] Desktop support
## 📩 Push ## 📩 Push
You need to install [ServerBoxMonitor](https://github.com/lollipopkit/server_box_monitor) on your servers. You need to install [ServerBoxMonitor](https://github.com/lollipopkit/server_box_monitor) on your servers.
And config iOS / Webhook to push server status to your portable device without using ServerBox app. And config `iOS / Webhook / ServerChan` to push server status to your portable device without using ServerBox app.
## 🆘 Help
If you have any question or feature request, please open a [discussion](https://github.com/lollipopkit/flutter_server_box/discussions/new/choose).
If ServerBox app has any bug, please open an [issue](https://github.com/lollipopkit/flutter_server_box/issues/new).
## 📱 ScreenShots ## 📱 ScreenShots
@@ -82,12 +86,12 @@ And config iOS / Webhook to push server status to your portable device without u
## 🖥 Platform ## 🖥 Platform
Status|Platform Status|Platform
--- | --- --- | ---
Full Support|Android/iOS Full Support| Android / iOS / macOS
Support, but not tested|macOS/Windows/Linux Support, but not tested| Windows / Linux
## l10n guide ## l10n guide
1. Fork this repo and clone it to your local machine. 1. Fork this repo and clone forked repo to your local machine.
2. Create `arb` file in `lib/l10n/` directory 2. Create `arb` file in `lib/l10n/` directory
- File name should be `intl_XX.arb`, where `XX` is the language code. Such as `intl_en.arb` for English and `intl_zh.arb` for Chinese. - File name should be `intl_XX.arb`, where `XX` is the language code. Such as `intl_en.arb` for English and `intl_zh.arb` for Chinese.
3. Add content to the file. You can refer to `intl_en.arb` and `intl_zh.arb` for the format. 3. Add content to the file. You can refer to `intl_en.arb` and `intl_zh.arb` for the format.

View File

@@ -33,15 +33,20 @@
- [x] `Docker & 包` 管理器 - [x] `Docker & 包` 管理器
- [x] 状态图表 - [x] 状态图表
- [x] `Ping` 和 更多 - [x] `Ping` 和 更多
- [x] 本地化 (英语, 中文) - [x] 本地化 ( 英语, 中文 )
- **欢迎贡献** :) - 欢迎贡献 :)[怎么贡献?](#l10n)
- [怎么贡献?](#l10n)
- [x] 桌面端支持 - [x] 桌面端支持
## 📩 推送 ## 📩 推送
你需要在你的服务器上安装 [ServerBoxMonitor](https://github.com/lollipopkit/server_box_monitor)。 你需要在你的服务器上安装 [ServerBoxMonitor](https://github.com/lollipopkit/server_box_monitor)。
并且配置 iOS / Webhook 推送服务,这样,你可以在不使用 ServerBox app 时获取服务器状态。 并且配置 `iOS / Webhook / Server酱` 推送服务,这样,你可以在不使用 ServerBox app 时获取服务器状态。
## 🆘 帮助
如果你有任何问题或者功能请求,请在 [讨论](https://github.com/lollipopkit/flutter_server_box/discussions/new/choose) 中交流。
如果 ServerBox app 有任何 bug请在 [问题](https://github.com/lollipopkit/flutter_server_box/issues/new) 中反馈。
## 📱 截屏 ## 📱 截屏
<table> <table>
@@ -81,8 +86,8 @@
## 🖥 平台 ## 🖥 平台
状态|平台 状态|平台
--- | --- --- | ---
完整支持|Android/iOS 完整支持 | Android / iOS / macOS
可能支持,未测试|macOS/Windows/Linux 可能支持,未测试 | Windows / Linux
## l10n ## l10n

View File

@@ -1,6 +1,23 @@
package tech.lolli.toolbox package tech.lolli.toolbox
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity: FlutterActivity() { class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val binaryMessenger = flutterEngine.dartExecutor.binaryMessenger
MethodChannel(binaryMessenger, "tech.lolli.toolbox/app_retain").apply {
setMethodCallHandler { method, result ->
if (method.method == "sendToBackground") {
moveTaskToBack(true)
result.success(null)
} else {
result.notImplemented()
}
}
}
}
} }

1
apk.sksl.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -359,15 +359,15 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 243; CURRENT_PROJECT_VERSION = 269;
DEVELOPMENT_TEAM = BA88US33G6; DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.243; MARKETING_VERSION = 1.0.269;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -490,15 +490,15 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 243; CURRENT_PROJECT_VERSION = 269;
DEVELOPMENT_TEAM = BA88US33G6; DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.243; MARKETING_VERSION = 1.0.269;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -515,15 +515,15 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 243; CURRENT_PROJECT_VERSION = 269;
DEVELOPMENT_TEAM = BA88US33G6; DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.243; MARKETING_VERSION = 1.0.269;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>zh</string>
</array>
<key>CFBundleName</key>
<string>ServerBox</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>NSBonjourServices</key>
<array>
<string>_dartobservatory._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>ServerBox needs to access your local network to discover and connect to your server.</string>
</dict>
</plist>

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>zh</string>
</array>
<key>CFBundleName</key>
<string>ServerBox</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>NSBonjourServices</key>
<array>
<string>_dartobservatory._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>ServerBox needs to access your local network to discover and connect to your server.</string>
</dict>
</plist>

1
ipa.sksl.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:toolbox/core/utils/misc.dart';
import '/core/extension/colorx.dart'; import '/core/extension/colorx.dart';
import 'core/utils/ui.dart'; import 'core/utils/ui.dart';
@@ -18,7 +17,6 @@ class MyApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
setTransparentNavigationBar(context); setTransparentNavigationBar(context);
final fontName = getFileName(_setting.fontPath.fetch());
primaryColor = Color(_setting.primaryColor.fetch()!); primaryColor = Color(_setting.primaryColor.fetch()!);
final textStyle = TextStyle(color: primaryColor); final textStyle = TextStyle(color: primaryColor);
@@ -51,7 +49,6 @@ class MyApp extends StatelessWidget {
final theme = ThemeData( final theme = ThemeData(
useMaterial3: false, useMaterial3: false,
fontFamily: fontName,
primaryColor: primaryColor, primaryColor: primaryColor,
primarySwatch: primarySwatch, primarySwatch: primarySwatch,
appBarTheme: appBarTheme, appBarTheme: appBarTheme,
@@ -64,7 +61,6 @@ class MyApp extends StatelessWidget {
); );
final darkTheme = ThemeData( final darkTheme = ThemeData(
useMaterial3: false, useMaterial3: false,
fontFamily: fontName,
primaryColor: primaryColor, primaryColor: primaryColor,
primarySwatch: primarySwatch, primarySwatch: primarySwatch,
floatingActionButtonTheme: fabTheme, floatingActionButtonTheme: fabTheme,

View File

@@ -3,8 +3,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:r_upgrade/r_upgrade.dart'; import 'package:r_upgrade/r_upgrade.dart';
import 'package:toolbox/core/utils/navigator.dart';
import '../data/model/app/update.dart';
import '../data/provider/app.dart'; import '../data/provider/app.dart';
import '../data/res/build_data.dart'; import '../data/res/build_data.dart';
import '../data/service/app.dart'; import '../data/service/app.dart';
@@ -27,18 +27,13 @@ Future<bool> isFileAvailable(String url) async {
Future<void> doUpdate(BuildContext context, {bool force = false}) async { Future<void> doUpdate(BuildContext context, {bool force = false}) async {
final update = await locator<AppService>().getUpdate(); final update = await locator<AppService>().getUpdate();
locator<AppProvider>().setNewestBuild(update.newest); final newest = update.build.last.current;
if (newest == null) {
_logger.warning('Update not available on $platform');
return;
}
final newest = () { locator<AppProvider>().setNewestBuild(newest);
if (isAndroid) {
return update.androidbuild;
} else if (isIOS) {
return update.iosbuild;
} else if (isMacOS) {
return update.macbuild;
}
return update.newest;
}();
if (!force && newest <= BuildData.build) { if (!force && newest <= BuildData.build) {
_logger.info('Update ignored due to current: ${BuildData.build}, ' _logger.info('Update ignored due to current: ${BuildData.build}, '
@@ -47,21 +42,23 @@ Future<void> doUpdate(BuildContext context, {bool force = false}) async {
} }
_logger.info('Update available: $newest'); _logger.info('Update available: $newest');
if (isAndroid && !await isFileAvailable(update.android)) { final url = update.url.current!;
if (isAndroid && !await isFileAvailable(url)) {
_logger.warning('Android update file not available'); _logger.warning('Android update file not available');
return; return;
} }
final s = S.of(context)!; final s = S.of(context)!;
if (update.min > BuildData.build) { if (update.build.min.current! > BuildData.build) {
showRoundDialog( showRoundDialog(
context, context,
s.attention, s.attention,
Text(s.updateTipTooLow(newest)), Text(s.updateTipTooLow(newest)),
[ [
TextButton( TextButton(
onPressed: () => _doUpdate(update, context, s), onPressed: () => _doUpdate(url, context, s),
child: Text(s.ok), child: Text(s.ok),
) )
], ],
@@ -71,17 +68,17 @@ Future<void> doUpdate(BuildContext context, {bool force = false}) async {
showSnackBarWithAction( showSnackBarWithAction(
context, context,
'${s.updateTip(newest)} \n${update.changelog}', '${s.updateTip(newest)} \n${update.changelog.current}',
s.update, s.update,
() => _doUpdate(update, context, s), () => _doUpdate(url, context, s),
); );
} }
Future<void> _doUpdate(AppUpdate update, BuildContext context, S s) async { Future<void> _doUpdate(String url, BuildContext context, S s) async {
if (isAndroid) { if (isAndroid) {
await RUpgrade.upgrade( await RUpgrade.upgrade(
update.android, url,
fileName: update.android.split('/').last, fileName: url.split('/').last,
isAutoRequestInstall: true, isAutoRequestInstall: true,
); );
} else if (isIOS) { } else if (isIOS) {
@@ -89,7 +86,7 @@ Future<void> _doUpdate(AppUpdate update, BuildContext context, S s) async {
} else { } else {
showRoundDialog(context, s.attention, Text(s.platformNotSupportUpdate), [ showRoundDialog(context, s.attention, Text(s.platformNotSupportUpdate), [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(s.ok), child: Text(s.ok),
) )
]); ]);

View File

@@ -7,9 +7,13 @@ import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:plain_notification_token/plain_notification_token.dart'; import 'package:plain_notification_token/plain_notification_token.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import '../../data/provider/app.dart';
import '../../locator.dart';
import '../../view/widget/rebuild.dart'; import '../../view/widget/rebuild.dart';
import 'platform.dart'; import 'platform.dart';
final _app = locator<AppProvider>();
Future<bool> shareFiles(BuildContext context, List<String> filePaths) async { Future<bool> shareFiles(BuildContext context, List<String> filePaths) async {
for (final filePath in filePaths) { for (final filePath in filePaths) {
if (!await File(filePath).exists()) { if (!await File(filePath).exists()) {
@@ -22,8 +26,10 @@ Future<bool> shareFiles(BuildContext context, List<String> filePaths) async {
} else { } else {
text = '${filePaths.length} ${S.of(context)!.files}'; text = '${filePaths.length} ${S.of(context)!.files}';
} }
final xfiles = filePaths.map((e) => XFile(e)).toList(); _app.setMoveBg(false);
await Share.shareXFiles(xfiles, text: 'ServerBox -> $text'); // ignore: deprecated_member_use
await Share.shareFiles(filePaths, text: 'ServerBox -> $text');
_app.setMoveBg(true);
return filePaths.isNotEmpty; return filePaths.isNotEmpty;
} }
@@ -32,7 +38,9 @@ void copy(String text) {
} }
Future<String?> pickOneFile() async { Future<String?> pickOneFile() async {
_app.setMoveBg(false);
final result = await FilePicker.platform.pickFiles(type: FileType.any); final result = await FilePicker.platform.pickFiles(type: FileType.any);
_app.setMoveBg(true);
return result?.files.single.path; return result?.files.single.path;
} }

View File

@@ -0,0 +1,7 @@
import 'package:flutter/material.dart';
extension ContextX on BuildContext {
void pop<T>([T? result]) {
Navigator.of(this).pop(T);
}
}

View File

@@ -9,6 +9,7 @@ enum PlatformType {
macos, macos,
windows, windows,
web, web,
unknown,
} }
final _p = () { final _p = () {
@@ -30,7 +31,7 @@ final _p = () {
if (Platform.isWindows) { if (Platform.isWindows) {
return PlatformType.windows; return PlatformType.windows;
} }
return PlatformType.web; return PlatformType.unknown;
}(); }();
PlatformType get platform => _p; PlatformType get platform => _p;
@@ -41,3 +42,8 @@ bool get isLinux => _p == PlatformType.linux;
bool get isMacOS => _p == PlatformType.macos; bool get isMacOS => _p == PlatformType.macos;
bool get isWindows => _p == PlatformType.windows; bool get isWindows => _p == PlatformType.windows;
bool get isWeb => _p == PlatformType.web; bool get isWeb => _p == PlatformType.web;
bool get isMobile => _p == PlatformType.ios || _p == PlatformType.android;
bool get isDesktop =>
_p == PlatformType.linux ||
_p == PlatformType.macos ||
_p == PlatformType.windows;

View File

@@ -9,7 +9,7 @@ import '../../locator.dart';
/// Must put this func out of any Class. /// Must put this func out of any Class.
/// ///
/// Because of this function is called by [compute] in [ServerProvider.genClient]. /// Because of this function is called by [compute].
/// ///
/// https://stackoverflow.com/questions/51998995/invalid-arguments-illegal-argument-in-isolate-message-object-is-a-closure /// https://stackoverflow.com/questions/51998995/invalid-arguments-illegal-argument-in-isolate-message-object-is-a-closure
List<SSHKeyPair> loadIndentity(String key) { List<SSHKeyPair> loadIndentity(String key) {

View File

@@ -3,11 +3,18 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:toolbox/core/utils/misc.dart'; import 'package:toolbox/core/utils/navigator.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../../data/model/server/snippet.dart';
import '../../data/provider/snippet.dart';
import '../../locator.dart';
import '../../view/page/snippet/edit.dart';
import '../../view/widget/card_dialog.dart'; import '../../view/widget/card_dialog.dart';
import '../../view/widget/picker.dart';
import '../persistant_store.dart'; import '../persistant_store.dart';
import '../route.dart';
import 'misc.dart';
import 'platform.dart'; import 'platform.dart';
import '../extension/stringx.dart'; import '../extension/stringx.dart';
import '../extension/uint8list.dart'; import '../extension/uint8list.dart';
@@ -107,8 +114,54 @@ String tabTitleName(BuildContext context, int i) {
Future<void> loadFontFile(String? localPath) async { Future<void> loadFontFile(String? localPath) async {
if (localPath == null) return; if (localPath == null) return;
final name = getFileName(localPath)!; final name = getFileName(localPath);
if (name == null) return;
var fontLoader = FontLoader(name); var fontLoader = FontLoader(name);
fontLoader.addFont(File(localPath).readAsBytes().byteData); fontLoader.addFont(File(localPath).readAsBytes().byteData);
await fontLoader.load(); await fontLoader.load();
} }
void showSnippetDialog(
BuildContext context, S s, Function(Snippet s) onSelected) {
final provider = locator<SnippetProvider>();
if (provider.snippets.isEmpty) {
showRoundDialog(
context,
s.attention,
Text(s.noSavedSnippet),
[
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(s.ok),
),
TextButton(
onPressed: () {
context.pop();
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 {
context.pop();
onSelected(snippet);
},
child: Text(s.ok),
)
],
);
}

View File

@@ -9,9 +9,7 @@ class Backup {
final List<ServerPrivateInfo> spis; final List<ServerPrivateInfo> spis;
final List<Snippet> snippets; final List<Snippet> snippets;
final List<PrivateKeyInfo> keys; final List<PrivateKeyInfo> keys;
final int primaryColor; final Map<String, String> dockerHosts;
final int serverStatusUpdateInterval;
final int launchPage;
Backup( Backup(
this.version, this.version,
@@ -19,9 +17,7 @@ class Backup {
this.spis, this.spis,
this.snippets, this.snippets,
this.keys, this.keys,
this.primaryColor, this.dockerHosts,
this.serverStatusUpdateInterval,
this.launchPage,
); );
Backup.fromJson(Map<String, dynamic> json) Backup.fromJson(Map<String, dynamic> json)
@@ -35,9 +31,7 @@ class Backup {
keys = (json['keys'] as List) keys = (json['keys'] as List)
.map((e) => PrivateKeyInfo.fromJson(e)) .map((e) => PrivateKeyInfo.fromJson(e))
.toList(), .toList(),
primaryColor = json['primaryColor'], dockerHosts = json['dockerHosts'] ?? {};
serverStatusUpdateInterval = json['serverStatusUpdateInterval'],
launchPage = json['launchPage'];
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'version': version, 'version': version,
@@ -45,8 +39,5 @@ class Backup {
'spis': spis, 'spis': spis,
'snippets': snippets, 'snippets': snippets,
'keys': keys, 'keys': keys,
'primaryColor': primaryColor,
'serverStatusUpdateInterval': serverStatusUpdateInterval,
'launchPage': launchPage,
}; };
} }

View File

@@ -1,54 +1,126 @@
///
/// Code generated by jsonToDartModel https://ashamp.github.io/jsonToDartModel/
///
class AppUpdate {
/* /*
{ {
"newest": 33, "changelog": {
"android": "https://v2.custed.lolli.tech/res/tiku/apk/33.apk", "mac": "xxx",
"ios": "https://", "ios": "xxx",
"min": 30, "android": ""
"changelog": "" },
} "build": {
"min": {
"mac": 1,
"ios": 1,
"android": 1
},
"last": {
"mac": 1,
"ios": 1,
"android": 1
}
},
"url": {
"mac": "https://apps.apple.com/app/id1586449703",
"ios": "https://apps.apple.com/app/id1586449703",
"android": "https://cdn3.cust.app/uploads/ServerBox_262_Arm64.apk"
}
}
*/ */
late int newest; import 'dart:convert';
late int iosbuild;
late int androidbuild;
late int macbuild;
late String android;
late String ios;
late String mac;
late int min;
late String changelog;
import '/core/utils/platform.dart';
class AppUpdate {
AppUpdate({ AppUpdate({
required this.newest,
required this.android,
required this.ios,
required this.min,
required this.changelog, required this.changelog,
required this.build,
required this.url,
}); });
AppUpdate.fromJson(Map<String, dynamic> json) {
newest = json["newest"]?.toInt(); final AppUpdatePlatformSpecific<String> changelog;
macbuild = json["macbuild"]?.toInt(); final Build build;
iosbuild = json["iosbuild"]?.toInt(); final AppUpdatePlatformSpecific<String> url;
androidbuild = json["androidbuild"]?.toInt();
android = json["android"].toString(); factory AppUpdate.fromRawJson(String str) =>
ios = json["ios"].toString(); AppUpdate.fromJson(json.decode(str));
min = json["min"].toInt();
changelog = json["changelog"].toString(); String toRawJson() => json.encode(toJson());
}
Map<String, dynamic> toJson() { factory AppUpdate.fromJson(Map<String, dynamic> json) => AppUpdate(
final Map<String, dynamic> data = <String, dynamic>{}; changelog: AppUpdatePlatformSpecific.fromJson(json["changelog"]),
data["newest"] = newest; build: Build.fromJson(json["build"]),
data["macbuild"] = macbuild; url: AppUpdatePlatformSpecific.fromJson(json["url"]),
data["iosbuild"] = iosbuild; );
data["androidbuild"] = androidbuild;
data["android"] = android; Map<String, dynamic> toJson() => {
data["ios"] = ios; "changelog": changelog.toJson(),
data["min"] = min; "build": build.toJson(),
data["changelog"] = changelog; "url": url.toJson(),
return data; };
}
class Build {
Build({
required this.min,
required this.last,
});
final AppUpdatePlatformSpecific<int> min;
final AppUpdatePlatformSpecific<int> last;
factory Build.fromRawJson(String str) => Build.fromJson(json.decode(str));
String toRawJson() => json.encode(toJson());
factory Build.fromJson(Map<String, dynamic> json) => Build(
min: AppUpdatePlatformSpecific.fromJson(json["min"]),
last: AppUpdatePlatformSpecific.fromJson(json["last"]),
);
Map<String, dynamic> toJson() => {
"min": min.toJson(),
"last": last.toJson(),
};
}
class AppUpdatePlatformSpecific<T> {
AppUpdatePlatformSpecific({
required this.mac,
required this.ios,
required this.android,
});
final T mac;
final T ios;
final T android;
factory AppUpdatePlatformSpecific.fromRawJson(String str) =>
AppUpdatePlatformSpecific.fromJson(json.decode(str));
String toRawJson() => json.encode(toJson());
factory AppUpdatePlatformSpecific.fromJson(Map<String, dynamic> json) =>
AppUpdatePlatformSpecific(
mac: json["mac"],
ios: json["ios"],
android: json["android"],
);
Map<String, dynamic> toJson() => {
"mac": mac,
"ios": ios,
"android": android,
};
T? get current {
switch (platform) {
case PlatformType.macos:
return mac;
case PlatformType.ios:
return ios;
case PlatformType.android:
return android;
default:
return null;
}
} }
} }

View File

@@ -114,10 +114,8 @@ List<OneTimeCpuStatus> parseCPU(String raw) {
final cpuTempReg = RegExp(r'(x86_pkg_temp|cpu_thermal)'); final cpuTempReg = RegExp(r'(x86_pkg_temp|cpu_thermal)');
String parseCPUTemp(List<String> segments) { String parseCPUTemp(String type, String value) {
const noMatch = "/sys/class/thermal/thermal_zone*/type"; const noMatch = "/sys/class/thermal/thermal_zone*/type";
final type = segments[0];
final value = segments[1];
// Not support to get CPU temperature // Not support to get CPU temperature
if (value.contains(noMatch) || if (value.contains(noMatch) ||
type.contains(noMatch) || type.contains(noMatch) ||

View File

@@ -6,9 +6,9 @@ class Server {
ServerPrivateInfo spi; ServerPrivateInfo spi;
ServerStatus status; ServerStatus status;
SSHClient? client; SSHClient? client;
ServerState cs; ServerState state;
Server(this.spi, this.status, this.client, this.cs); Server(this.spi, this.status, this.client, this.state);
} }
enum ServerState { enum ServerState {

View File

@@ -12,4 +12,4 @@ class VirtualKey {
{this.key, this.toggleable = false, this.icon, this.func}); {this.key, this.toggleable = false, this.icon, this.func});
} }
enum VirtualKeyFunc { toggleIME, backspace, copy, paste } enum VirtualKeyFunc { toggleIME, backspace, copy, paste, snippet }

View File

@@ -4,8 +4,15 @@ class AppProvider extends BusyProvider {
int? _newestBuild; int? _newestBuild;
int? get newestBuild => _newestBuild; int? get newestBuild => _newestBuild;
bool _moveBg = true;
bool get moveBg => _moveBg;
void setNewestBuild(int build) { void setNewestBuild(int build) {
_newestBuild = build; _newestBuild = build;
notifyListeners(); notifyListeners();
} }
void setMoveBg(bool moveBg) {
_moveBg = moveBg;
}
} }

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:toolbox/data/model/server/server_status.dart';
import '../../core/extension/uint8list.dart'; import '../../core/extension/uint8list.dart';
import '../../core/provider_base.dart'; import '../../core/provider_base.dart';
@@ -49,7 +50,7 @@ class ServerProvider extends BusyProvider {
} }
await Future.wait(_servers.map((s) async { await Future.wait(_servers.map((s) async {
if (onlyFailed) { if (onlyFailed) {
if (s.cs != ServerState.failed) return; if (s.state != ServerState.failed) return;
_limiter.resetTryTimes(s.spi.id); _limiter.resetTryTimes(s.spi.id);
} }
await _getData(s.spi); await _getData(s.spi);
@@ -75,7 +76,7 @@ class ServerProvider extends BusyProvider {
void setDisconnected() { void setDisconnected() {
for (var i = 0; i < _servers.length; i++) { for (var i = 0; i < _servers.length; i++) {
_servers[i].cs = ServerState.disconnected; _servers[i].state = ServerState.disconnected;
} }
_limiter.clear(); _limiter.clear();
notifyListeners(); notifyListeners();
@@ -135,14 +136,14 @@ class ServerProvider extends BusyProvider {
Future<void> _getData(ServerPrivateInfo spi) async { Future<void> _getData(ServerPrivateInfo spi) async {
final sid = spi.id; final sid = spi.id;
final s = getServer(sid); final s = getServer(sid);
final state = s.cs; final state = s.state;
if (state.shouldConnect) { if (state.shouldConnect) {
if (!_limiter.shouldTry(sid)) { if (!_limiter.shouldTry(sid)) {
s.cs = ServerState.failed; s.state = ServerState.failed;
notifyListeners(); notifyListeners();
return; return;
} }
s.cs = ServerState.connecting; s.state = ServerState.connecting;
notifyListeners(); notifyListeners();
try { try {
@@ -154,7 +155,7 @@ class ServerProvider extends BusyProvider {
_logger.info('Connected to [$sid] in $spentTime ms.'); _logger.info('Connected to [$sid] in $spentTime ms.');
// after connected // after connected
s.cs = ServerState.connected; s.state = ServerState.connected;
final writeResult = await s.client!.run(installShellCmd).string; final writeResult = await s.client!.run(installShellCmd).string;
// if write failed // if write failed
@@ -163,8 +164,8 @@ class ServerProvider extends BusyProvider {
} }
_limiter.resetTryTimes(sid); _limiter.resetTryTimes(sid);
} catch (e) { } catch (e) {
s.cs = ServerState.failed; s.state = ServerState.failed;
s.status.failedInfo = '$e'; s.status.failedInfo = e.toString();
_logger.warning(e); _logger.warning(e);
} finally { } finally {
notifyListeners(); notifyListeners();
@@ -176,7 +177,7 @@ class ServerProvider extends BusyProvider {
final raw = await s.client!.run("sh $shellPath").string; final raw = await s.client!.run("sh $shellPath").string;
final segments = raw.split(seperator).map((e) => e.trim()).toList(); final segments = raw.split(seperator).map((e) => e.trim()).toList();
if (raw.isEmpty || segments.length == 1) { if (raw.isEmpty || segments.length == 1) {
s.cs = ServerState.failed; s.state = ServerState.failed;
if (s.status.failedInfo == null || s.status.failedInfo!.isEmpty) { if (s.status.failedInfo == null || s.status.failedInfo!.isEmpty) {
s.status.failedInfo = 'Seperate segments failed, raw:\n$raw'; s.status.failedInfo = 'Seperate segments failed, raw:\n$raw';
} }
@@ -184,18 +185,14 @@ class ServerProvider extends BusyProvider {
return; return;
} }
// remove first empty segment // remove first empty segment
// for `export xxx` in shell script
segments.removeAt(0); segments.removeAt(0);
try { try {
_getCPU(sid, segments[2], segments[7], segments[8]); final req = ServerStatusUpdateReq(s.status, segments);
_getMem(sid, segments[6]); s.status = await compute(_getStatus, req);
_getSysVer(sid, segments[1]);
_getUpTime(sid, segments[3]);
_getDisk(sid, segments[5]);
_getTcp(sid, segments[4]);
_getNetSpeed(sid, segments[0]);
} catch (e) { } catch (e) {
s.cs = ServerState.failed; s.state = ServerState.failed;
s.status.failedInfo = e.toString(); s.status.failedInfo = e.toString();
_logger.warning(e); _logger.warning(e);
rethrow; rethrow;
@@ -204,50 +201,6 @@ class ServerProvider extends BusyProvider {
} }
} }
Future<void> _getNetSpeed(String id, String raw) async {
final net = await compute(parseNetSpeed, raw);
getServer(id).status.netSpeed.update(net);
}
void _getSysVer(String id, String raw) {
final s = raw.split('=');
if (s.length == 2) {
final ver = s[1].replaceAll('"', '').replaceFirst('\n', '');
getServer(id).status.sysVer = ver;
}
}
Future<void> _getCPU(
String id, String raw, String tempType, String tempValue) async {
final cpus = await compute(parseCPU, raw);
final temp = await compute(parseCPUTemp, [tempType, tempValue]);
if (cpus.isNotEmpty) {
getServer(id).status.cpu.update(cpus, temp);
}
}
void _getUpTime(String id, String raw) {
getServer(id).status.uptime = raw.split('up ')[1].split(', ')[0];
}
Future<void> _getTcp(String id, String raw) async {
final status = await compute(parseTcp, raw);
if (status != null) {
getServer(id).status.tcp = status;
}
}
Future<void> _getDisk(String id, String raw) async {
getServer(id).status.disk = await compute(parseDisk, raw);
}
Future<void> _getMem(String id, String raw) async {
final s = getServer(id);
s.status.mem = await compute(parseMem, raw);
s.status.swap = await compute(parseSwap, raw);
}
Future<String?> runSnippet(String id, Snippet snippet) async { Future<String?> runSnippet(String id, Snippet snippet) async {
final client = getServer(id).client; final client = getServer(id).client;
if (client == null) { if (client == null) {
@@ -281,3 +234,55 @@ class _TryLimiter {
_triedTimes.clear(); _triedTimes.clear();
} }
} }
class ServerStatusUpdateReq {
final ServerStatus ss;
final List<String> segments;
const ServerStatusUpdateReq(this.ss, this.segments);
}
Future<ServerStatus> _getStatus(ServerStatusUpdateReq req) async {
final net = parseNetSpeed(req.segments[0]);
req.ss.netSpeed.update(net);
final sys = parseSysVer(req.segments[1]);
if (sys != null) {
req.ss.sysVer = sys;
}
final cpus = parseCPU(req.segments[2]);
final cpuTemp = parseCPUTemp(req.segments[7], req.segments[8]);
req.ss.cpu.update(cpus, cpuTemp);
final tcp = parseTcp(req.segments[4]);
if (tcp != null) {
req.ss.tcp = tcp;
}
req.ss.disk = parseDisk(req.segments[5]);
req.ss.mem = parseMem(req.segments[6]);
final uptime = parseUpTime(req.segments[3]);
if (uptime != null) {
req.ss.uptime = uptime;
}
req.ss.swap = parseSwap(req.segments[6]);
return req.ss;
}
// raw:
// 19:39:15 up 61 days, 18:16, 1 user, load average: 0.00, 0.00, 0.00
String? parseUpTime(String raw) {
final splitedUp = raw.split('up ');
if (splitedUp.length == 2) {
final splitedComma = splitedUp[1].split(', ');
if (splitedComma.length >= 2) {
return splitedComma[0];
}
}
return null;
}
String? parseSysVer(String raw) {
final s = raw.split('=');
if (s.length == 2) {
return s[1].replaceAll('"', '').replaceFirst('\n', '');
}
return null;
}

View File

@@ -2,8 +2,8 @@
class BuildData { class BuildData {
static const String name = "ServerBox"; static const String name = "ServerBox";
static const int build = 243; static const int build = 269;
static const String engine = "Flutter 3.7.7 • channel stable • https://github.com/flutter/flutter.git\nFramework • revision 2ad6cd72c0 (13 days ago) • 2023-03-08 09:41:59 -0800\nEngine • revision 1837b5be5f\nTools • Dart 2.19.4 • DevTools 2.20.1\n"; static const String engine = "3.7.11";
static const String buildAt = "2023-03-21 15:18:42.802605"; static const String buildAt = "2023-04-19 20:53:37.738261";
static const int modifications = 5; static const int modifications = 15;
} }

View File

@@ -1,3 +1,5 @@
import 'package:flutter/services.dart';
/// RegExp for number /// RegExp for number
final numReg = RegExp(r'\s{1,}'); final numReg = RegExp(r'\s{1,}');
@@ -6,3 +8,7 @@ const privateKeyMaxSize = 20 * 1024;
/// Max debug log lines /// Max debug log lines
const maxDebugLogLines = 100; const maxDebugLogLines = 100;
/// Method Channels
const pkgName = 'tech.lolli.toolbox';
const bgRunChannel = MethodChannel('$pkgName/app_retain');

View File

@@ -1,10 +1,20 @@
import 'dart:io'; import 'dart:io';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:toolbox/core/utils/platform.dart';
Future<Directory> get docDir async => await getApplicationDocumentsDirectory(); Future<Directory> get docDir async {
if (isAndroid) {
final dir = await getExternalStorageDirectory();
if (dir != null) {
return dir;
}
// fallthrough to getApplicationDocumentsDirectory
}
return await getApplicationDocumentsDirectory();
}
Future<Directory> get sftpDownloadDir async { Future<Directory> get sftpDir async {
final dir = Directory('${(await docDir).path}/sftp'); final dir = Directory('${(await docDir).path}/sftp');
return dir.create(recursive: true); return dir.create(recursive: true);
} }

View File

@@ -1,5 +1,5 @@
const backendUrl = 'https://res.lolli.tech'; const backendUrl = 'https://res.lolli.tech';
const baseUrl = '$backendUrl/toolbox'; const baseUrl = '$backendUrl/serverbox';
const joinQQGroupUrl = 'https://jq.qq.com/?_wv=1027&k=G0hUmPAq'; const joinQQGroupUrl = 'https://jq.qq.com/?_wv=1027&k=G0hUmPAq';
const myGithub = 'https://github.com/lollipopkit'; const myGithub = 'https://github.com/lollipopkit';
const issueUrl = '$myGithub/flutter_server_box/issues'; const issueUrl = '$myGithub/flutter_server_box/issues';
@@ -11,4 +11,5 @@ const thanksMap = {
'Tao173': 'https://github.com/Tao173', 'Tao173': 'https://github.com/Tao173',
'QingAnLe': 'https://github.com/QingAnLe', 'QingAnLe': 'https://github.com/QingAnLe',
'wxdjs': 'https://github.com/wxdjs', 'wxdjs': 'https://github.com/wxdjs',
'Aeorq': 'https://github.com/Aeorq',
}; };

View File

@@ -3,22 +3,24 @@ import 'package:xterm/core.dart';
import '../model/ssh/virtual_key.dart'; import '../model/ssh/virtual_key.dart';
var virtualKeys = [ final virtualKeys = [
VirtualKey('Esc', key: TerminalKey.escape), VirtualKey('Esc', key: TerminalKey.escape),
VirtualKey('Alt', key: TerminalKey.alt, toggleable: true), VirtualKey('Alt', key: TerminalKey.alt, toggleable: true),
VirtualKey('Home', key: TerminalKey.home), VirtualKey('Home', key: TerminalKey.home),
VirtualKey('Up', key: TerminalKey.arrowUp, icon: Icons.arrow_upward), VirtualKey('Up', key: TerminalKey.arrowUp, icon: Icons.arrow_upward),
VirtualKey('End', key: TerminalKey.end), VirtualKey('End', key: TerminalKey.end),
// VirtualKey( VirtualKey(
// 'Del', 'Del',
// key: TerminalKey.delete, key: TerminalKey.delete,
// ), icon: Icons.backspace,
VirtualKey('Paste', func: VirtualKeyFunc.paste, icon: Icons.paste), ),
VirtualKey('Snippet', func: VirtualKeyFunc.snippet, icon: Icons.code),
VirtualKey('Tab', key: TerminalKey.tab), VirtualKey('Tab', key: TerminalKey.tab),
VirtualKey('Ctrl', key: TerminalKey.control, toggleable: true), VirtualKey('Ctrl', key: TerminalKey.control, toggleable: true),
VirtualKey('Left', key: TerminalKey.arrowLeft, icon: Icons.arrow_back), VirtualKey('Left', key: TerminalKey.arrowLeft, icon: Icons.arrow_back),
VirtualKey('Down', key: TerminalKey.arrowDown, icon: Icons.arrow_downward), VirtualKey('Down', key: TerminalKey.arrowDown, icon: Icons.arrow_downward),
VirtualKey('Right', key: TerminalKey.arrowRight, icon: Icons.arrow_forward), VirtualKey('Right', key: TerminalKey.arrowRight, icon: Icons.arrow_forward),
VirtualKey('Paste', func: VirtualKeyFunc.paste, icon: Icons.paste),
VirtualKey( VirtualKey(
'IME', 'IME',
func: VirtualKeyFunc.toggleIME, func: VirtualKeyFunc.toggleIME,

View File

@@ -8,4 +8,8 @@ class DockerStore extends PersistentStore {
void setDockerHost(String id, String host) { void setDockerHost(String id, String host) {
box.put(id, host); box.put(id, host);
} }
Map<String, String> fetch() {
return box.toMap().cast<String, String>();
}
} }

View File

@@ -28,11 +28,14 @@ class SettingStore extends PersistentStore {
/// Max retry count when connect to server /// Max retry count when connect to server
StoreProperty<int> get maxRetryCount => StoreProperty<int> get maxRetryCount =>
property('maxRetryCount', defaultValue: 7); property('maxRetryCount', defaultValue: 2);
/// Night mode: 0 -> auto, 1 -> light, 2 -> dark /// Night mode: 0 -> auto, 1 -> light, 2 -> dark
StoreProperty<int> get themeMode => property('themeMode', defaultValue: 0); StoreProperty<int> get themeMode => property('themeMode', defaultValue: 0);
/// Font file path /// Font file path
StoreProperty<String> get fontPath => property('fontPath'); StoreProperty<String> get fontPath => property('fontPath');
/// Backgroud running (Android)
StoreProperty<bool> get bgRun => property('bgRun', defaultValue: true);
} }

View File

@@ -10,8 +10,10 @@
"auto": "Auto", "auto": "Auto",
"backDir": "Back", "backDir": "Back",
"backup": "Backup", "backup": "Backup",
"backupTip": "The exported data is simply encrypted. \nPlease keep it safe.\nRestoring will not overwrite existing data (except setting).", "backupAndRestore": "Backup and Restore",
"backupTip": "The exported data is simply encrypted. \nPlease keep it safe.",
"backupVersionNotMatch": "Backup version is not match.", "backupVersionNotMatch": "Backup version is not match.",
"bgRun": "Run in backgroud",
"cancel": "Cancel", "cancel": "Cancel",
"choose": "Choose", "choose": "Choose",
"chooseDestination": "Choose destination", "chooseDestination": "Choose destination",
@@ -68,7 +70,6 @@
"httpFailedWithCode": "request failed, status code: {code}", "httpFailedWithCode": "request failed, status code: {code}",
"imagesList": "Images list", "imagesList": "Images list",
"import": "Import", "import": "Import",
"importAndExport": "Import and Export",
"inputDomainHere": "Input Domain here", "inputDomainHere": "Input Domain here",
"install": "install", "install": "install",
"installDockerWithUrl": "Please https://docs.docker.com/engine/install docker first.", "installDockerWithUrl": "Please https://docs.docker.com/engine/install docker first.",
@@ -84,7 +85,7 @@
"light": "Light", "light": "Light",
"loadingFiles": "Loading files...", "loadingFiles": "Loading files...",
"loss": "loss", "loss": "loss",
"madeWithLove": "\nMade with ❤️ by {myGithub}", "madeWithLove": "Made with ❤️ by {myGithub}",
"max": "max", "max": "max",
"maxRetryCount": "Number of server reconnection", "maxRetryCount": "Number of server reconnection",
"maxRetryCountEqual0": "Will retry again and again.", "maxRetryCountEqual0": "Will retry again and again.",
@@ -106,6 +107,7 @@
"onServerDetailPage": "On server detail page", "onServerDetailPage": "On server detail page",
"onlyIOS": "Only valid on iOS", "onlyIOS": "Only valid on iOS",
"open": "Open", "open": "Open",
"paste": "Paste",
"path": "Path", "path": "Path",
"pickFile": "Pick file", "pickFile": "Pick file",
"ping": "Ping", "ping": "Ping",
@@ -150,6 +152,7 @@
"stop": "Stop", "stop": "Stop",
"success": "Success", "success": "Success",
"sureDelete": "Are you sure to delete [{name}]?", "sureDelete": "Are you sure to delete [{name}]?",
"sureDirEmpty": "Make sure dir is empty.",
"sureNoPwd": "Are you sure to use no password?", "sureNoPwd": "Are you sure to use no password?",
"sureToDeleteServer": "Are you sure to delete server [{server}]?", "sureToDeleteServer": "Are you sure to delete server [{server}]?",
"termTheme": "Terminal theme", "termTheme": "Terminal theme",

View File

@@ -10,8 +10,10 @@
"auto": "自动", "auto": "自动",
"backDir": "返回上一级", "backDir": "返回上一级",
"backup": "备份", "backup": "备份",
"backupTip": "导出的数据仅进行了简单加密,请妥善保管。\n除了设置项恢复的数据不会覆盖现有数据。", "backupAndRestore": "备份和恢复",
"backupTip": "导出的数据仅进行了简单加密,请妥善保管。",
"backupVersionNotMatch": "备份版本不匹配,无法恢复", "backupVersionNotMatch": "备份版本不匹配,无法恢复",
"bgRun": "后台运行",
"cancel": "取消", "cancel": "取消",
"choose": "选择", "choose": "选择",
"chooseDestination": "选择目标", "chooseDestination": "选择目标",
@@ -68,7 +70,6 @@
"httpFailedWithCode": "请求失败, 状态码: {code}", "httpFailedWithCode": "请求失败, 状态码: {code}",
"imagesList": "镜像列表", "imagesList": "镜像列表",
"import": "导入", "import": "导入",
"importAndExport": "导入或导出",
"inputDomainHere": "在这里输入域名", "inputDomainHere": "在这里输入域名",
"install": "安装", "install": "安装",
"installDockerWithUrl": "请先 https://docs.docker.com/engine/install docker", "installDockerWithUrl": "请先 https://docs.docker.com/engine/install docker",
@@ -84,7 +85,7 @@
"light": "亮", "light": "亮",
"loadingFiles": "正在加载目录。。。", "loadingFiles": "正在加载目录。。。",
"loss": "丢包率", "loss": "丢包率",
"madeWithLove": "\n用❤️制作 by {myGithub}", "madeWithLove": "用❤️制作 by {myGithub}",
"max": "最大", "max": "最大",
"maxRetryCount": "服务器尝试重连次数", "maxRetryCount": "服务器尝试重连次数",
"maxRetryCountEqual0": "会无限重试", "maxRetryCountEqual0": "会无限重试",
@@ -106,6 +107,7 @@
"onServerDetailPage": "在服务器详情页", "onServerDetailPage": "在服务器详情页",
"onlyIOS": "仅在iOS上有效", "onlyIOS": "仅在iOS上有效",
"open": "打开", "open": "打开",
"paste": "粘贴",
"path": "路径", "path": "路径",
"pickFile": "选择文件", "pickFile": "选择文件",
"ping": "Ping", "ping": "Ping",
@@ -150,6 +152,7 @@
"stop": "停止", "stop": "停止",
"success": "成功", "success": "成功",
"sureDelete": "确定删除[{name}]", "sureDelete": "确定删除[{name}]",
"sureDirEmpty": "请确保文件夹为空",
"sureNoPwd": "确认使用无密码?", "sureNoPwd": "确认使用无密码?",
"sureToDeleteServer": "你确定要删除服务器 [{server}] 吗?", "sureToDeleteServer": "你确定要删除服务器 [{server}] 吗?",
"termTheme": "终端主题", "termTheme": "终端主题",

View File

@@ -1,19 +1,22 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:toolbox/data/res/color.dart'; import 'package:toolbox/core/utils/navigator.dart';
import 'package:toolbox/data/res/path.dart';
import '../../core/extension/colorx.dart'; import '../../core/extension/colorx.dart';
import '../../core/utils/misc.dart';
import '../../core/utils/ui.dart'; import '../../core/utils/ui.dart';
import '../../data/model/app/backup.dart'; import '../../data/model/app/backup.dart';
import '../../data/res/color.dart';
import '../../data/res/ui.dart'; import '../../data/res/ui.dart';
import '../../data/store/docker.dart';
import '../../data/store/private_key.dart'; import '../../data/store/private_key.dart';
import '../../data/store/server.dart'; import '../../data/store/server.dart';
import '../../data/store/setting.dart';
import '../../data/store/snippet.dart'; import '../../data/store/snippet.dart';
import '../../locator.dart'; import '../../locator.dart';
@@ -22,10 +25,10 @@ const backupFormatVersion = 1;
class BackupPage extends StatelessWidget { class BackupPage extends StatelessWidget {
BackupPage({Key? key}) : super(key: key); BackupPage({Key? key}) : super(key: key);
final _setting = locator<SettingStore>();
final _server = locator<ServerStore>(); final _server = locator<ServerStore>();
final _snippet = locator<SnippetStore>(); final _snippet = locator<SnippetStore>();
final _privateKey = locator<PrivateKeyStore>(); final _privateKey = locator<PrivateKeyStore>();
final _dockerHosts = locator<DockerStore>();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -33,7 +36,7 @@ class BackupPage extends StatelessWidget {
final s = S.of(context)!; final s = S.of(context)!;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(s.importAndExport, style: textSize18), title: Text(s.backupAndRestore, style: textSize18),
), ),
body: Center( body: Center(
child: Column( child: Column(
@@ -50,8 +53,20 @@ class BackupPage extends StatelessWidget {
const SizedBox( const SizedBox(
height: 107, height: 107,
), ),
_buildCard(s.restore, Icons.download, media, _buildCard(s.restore, Icons.download, media, () async {
() => _showImportDialog(context, s)), final path = await pickOneFile();
if (path == null) {
showSnackBar(context, Text(s.notSelected));
return;
}
final file = File(path);
if (!file.existsSync()) {
showSnackBar(context, Text(s.fileNotExist(path)));
return;
}
final text = await file.readAsString();
_import(text, context, s);
}),
const SizedBox(height: 7), const SizedBox(height: 7),
const Divider(), const Divider(),
const SizedBox(height: 7), const SizedBox(height: 7),
@@ -59,7 +74,23 @@ class BackupPage extends StatelessWidget {
s.backup, s.backup,
Icons.file_upload, Icons.file_upload,
media, media,
() => _showExportDialog(context, s), () async {
final result = _diyEncrtpt(
json.encode(
Backup(
backupFormatVersion,
DateTime.now().toString().split('.').first,
_server.fetch(),
_snippet.fetch(),
_privateKey.fetch(),
_dockerHosts.fetch(),
),
),
);
final path = '${(await docDir).path}/srvbox_bak.json';
await File(path).writeAsString(result);
await shareFiles(context, [path]);
},
) )
], ],
)), )),
@@ -93,77 +124,12 @@ class BackupPage extends StatelessWidget {
); );
} }
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 { Future<void> _import(String text, BuildContext context, S s) async {
if (text.isEmpty) { if (text.isEmpty) {
showSnackBar(context, Text(s.fieldMustNotEmpty)); showSnackBar(context, Text(s.fieldMustNotEmpty));
return; return;
} }
_importBackup(text, context, s); await _importBackup(text, context, s);
Navigator.of(context).pop();
} }
Future<void> _importBackup(String raw, BuildContext context, S s) async { Future<void> _importBackup(String raw, BuildContext context, S s) async {
@@ -180,7 +146,7 @@ class BackupPage extends StatelessWidget {
Text(s.restoreSureWithDate(backup.date)), Text(s.restoreSureWithDate(backup.date)),
[ [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(s.cancel), child: Text(s.cancel),
), ),
TextButton( TextButton(
@@ -194,19 +160,32 @@ class BackupPage extends StatelessWidget {
for (final s in backup.keys) { for (final s in backup.keys) {
_privateKey.put(s); _privateKey.put(s);
} }
_setting.primaryColor.put(backup.primaryColor); for (final k in backup.dockerHosts.keys) {
_setting.serverStatusUpdateInterval _dockerHosts.setDockerHost(k, backup.dockerHosts[k]!);
.put(backup.serverStatusUpdateInterval); }
_setting.launchPage.put(backup.launchPage); context.pop();
Navigator.of(context).pop(); showRoundDialog(
showSnackBar(context, Text(s.restoreSuccess)); context,
s.attention,
Text(s.restoreSuccess),
[
TextButton(
onPressed: () => rebuildAll(context),
child: Text(s.restart),
),
TextButton(
onPressed: () => context.pop(),
child: Text(s.cancel),
),
],
);
}, },
child: Text(s.ok), child: Text(s.ok),
), ),
], ],
); );
} catch (e) { } catch (e) {
showSnackBar(context, Text(s.invalidJson)); showSnackBar(context, Text(e.toString()));
return; return;
} }
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:toolbox/core/utils/navigator.dart';
import '../../core/utils/ui.dart'; import '../../core/utils/ui.dart';
import '../../data/model/docker/ps.dart'; import '../../data/model/docker/ps.dart';
@@ -50,7 +51,7 @@ class _DockerManagePageState extends State<DockerManagePage> {
final client = locator<ServerProvider>().getServer(widget.spi.id).client; final client = locator<ServerProvider>().getServer(widget.spi.id).client;
if (client == null) { if (client == null) {
showSnackBar(context, Text(_s.noClient)); showSnackBar(context, Text(_s.noClient));
Navigator.of(context).pop(); context.pop();
return; return;
} }
_docker.init(client, widget.spi.user, onPwdRequest, widget.spi.id); _docker.init(client, widget.spi.user, onPwdRequest, widget.spi.id);
@@ -119,12 +120,12 @@ class _DockerManagePageState extends State<DockerManagePage> {
), ),
[ [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(_s.cancel), child: Text(_s.cancel),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Navigator.of(context).pop(); context.pop();
await _showAddCmdPreview( await _showAddCmdPreview(
_buildAddCmd( _buildAddCmd(
imageCtrl.text.trim(), imageCtrl.text.trim(),
@@ -146,12 +147,12 @@ class _DockerManagePageState extends State<DockerManagePage> {
Text(cmd), Text(cmd),
[ [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(_s.cancel), child: Text(_s.cancel),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Navigator.of(context).pop(); context.pop();
final result = await _docker.run(cmd); final result = await _docker.run(cmd);
if (result != null) { if (result != null) {
showSnackBar(context, Text(getErrMsg(result) ?? _s.unknownError)); showSnackBar(context, Text(getErrMsg(result) ?? _s.unknownError));
@@ -187,13 +188,13 @@ class _DockerManagePageState extends State<DockerManagePage> {
if (_textController.text == '') { if (_textController.text == '') {
showRoundDialog(context, _s.attention, Text(_s.fieldMustNotEmpty), [ showRoundDialog(context, _s.attention, Text(_s.fieldMustNotEmpty), [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(_s.ok), child: Text(_s.ok),
), ),
]); ]);
return; return;
} }
Navigator.of(context).pop(); context.pop();
} }
Future<String> onPwdRequest() async { Future<String> onPwdRequest() async {
@@ -213,8 +214,8 @@ class _DockerManagePageState extends State<DockerManagePage> {
[ [
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(); context.pop();
Navigator.of(context).pop(); context.pop();
}, },
child: Text(_s.cancel), child: Text(_s.cancel),
), ),
@@ -294,12 +295,12 @@ class _DockerManagePageState extends State<DockerManagePage> {
Text(_s.sureDelete(e.repo)), Text(_s.sureDelete(e.repo)),
[ [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(_s.cancel), child: Text(_s.cancel),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Navigator.of(context).pop(); context.pop();
final result = await _docker.run( final result = await _docker.run(
'docker rmi ${e.id} -f', 'docker rmi ${e.id} -f',
); );
@@ -375,12 +376,12 @@ class _DockerManagePageState extends State<DockerManagePage> {
onSubmitted: (value) { onSubmitted: (value) {
locator<DockerStore>().setDockerHost(widget.spi.id, value.trim()); locator<DockerStore>().setDockerHost(widget.spi.id, value.trim());
_docker.refresh(); _docker.refresh();
Navigator.of(context).pop(); context.pop();
}, },
), ),
[ [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(_s.cancel), child: Text(_s.cancel),
), ),
], ],
@@ -478,7 +479,7 @@ class _DockerManagePageState extends State<DockerManagePage> {
[ [
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(); context.pop();
_docker.delete(dItem.containerId); _docker.delete(dItem.containerId);
}, },
child: Text(_s.ok), child: Text(_s.ok),

View File

@@ -2,10 +2,14 @@ import 'package:after_layout/after_layout.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:toolbox/core/utils/navigator.dart';
import 'package:toolbox/data/provider/app.dart';
import 'package:toolbox/data/res/misc.dart';
import '../../core/analysis.dart'; import '../../core/analysis.dart';
import '../../core/route.dart'; import '../../core/route.dart';
import '../../core/update.dart'; import '../../core/update.dart';
import '../../core/utils/platform.dart';
import '../../core/utils/ui.dart'; import '../../core/utils/ui.dart';
import '../../data/model/app/dynamic_color.dart'; import '../../data/model/app/dynamic_color.dart';
import '../../data/model/app/navigation_item.dart'; import '../../data/model/app/navigation_item.dart';
@@ -42,8 +46,11 @@ class _MyHomePageState extends State<MyHomePage>
AutomaticKeepAliveClientMixin, AutomaticKeepAliveClientMixin,
AfterLayoutMixin, AfterLayoutMixin,
WidgetsBindingObserver { WidgetsBindingObserver {
late final ServerProvider _serverProvider; final _serverProvider = locator<ServerProvider>();
final _setting = locator<SettingStore>();
late final PageController _pageController; late final PageController _pageController;
late int _selectIndex; late int _selectIndex;
late double _width; late double _width;
late S _s; late S _s;
@@ -51,9 +58,8 @@ class _MyHomePageState extends State<MyHomePage>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_serverProvider = locator<ServerProvider>();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_selectIndex = locator<SettingStore>().launchPage.fetch()!; _selectIndex = _setting.launchPage.fetch()!;
_pageController = PageController(initialPage: _selectIndex); _pageController = PageController(initialPage: _selectIndex);
} }
@@ -74,12 +80,27 @@ class _MyHomePageState extends State<MyHomePage>
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state); super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.paused) { if (isDesktop) return;
_serverProvider.setDisconnected();
_serverProvider.stopAutoRefresh(); switch (state) {
} case AppLifecycleState.resumed:
if (state == AppLifecycleState.resumed) { if (isIOS) {
_serverProvider.startAutoRefresh(); _serverProvider.startAutoRefresh();
}
break;
case AppLifecycleState.paused:
// Keep running in background on Android device
if (isAndroid && _setting.bgRun.fetch()!) {
if (locator<AppProvider>().moveBg) {
bgRunChannel.invokeMethod('sendToBackground');
}
} else {
_serverProvider.setDisconnected();
_serverProvider.stopAutoRefresh();
}
break;
default:
break;
} }
} }
@@ -233,7 +254,7 @@ class _MyHomePageState extends State<MyHomePage>
child: Text(_s.feedback), child: Text(_s.feedback),
), ),
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(_s.close), child: Text(_s.close),
) )
], ],
@@ -245,27 +266,44 @@ class _MyHomePageState extends State<MyHomePage>
onTap: () => AppRoute(const SnippetListPage(), 'snippet list') onTap: () => AppRoute(const SnippetListPage(), 'snippet list')
.go(context), .go(context),
), ),
AboutListTile( ListTile(
icon: const Icon(Icons.text_snippet), leading: const Icon(Icons.text_snippet),
applicationName: '\n${BuildData.name}', title: Text(_s.about),
applicationVersion: _versionStr, onTap: () {
applicationIcon: _buildIcon(), showRoundDialog(
aboutBoxChildren: [ context,
UrlText( _s.about,
text: _s.madeWithLove(myGithub), Column(
replace: 'lollipopkit'), mainAxisSize: MainAxisSize.min,
UrlText( crossAxisAlignment: CrossAxisAlignment.start,
text: _s.aboutThanks, children: [
), UrlText(
// Thanks text: _s.madeWithLove(myGithub),
...thanksMap.keys.map( replace: 'lollipopkit'),
(key) => UrlText( UrlText(
text: thanksMap[key] ?? '', text: _s.aboutThanks,
replace: key, ),
// Thanks
...thanksMap.keys.map(
(key) => UrlText(
text: thanksMap[key] ?? '',
replace: key,
),
)
],
), ),
) [
], TextButton(
child: Text(_s.about), onPressed: () => showLicensePage(context: context),
child: Text(_s.license),
),
TextButton(
onPressed: () => context.pop(),
child: Text(_s.close),
)
],
);
},
) )
], ],
), ),
@@ -307,7 +345,8 @@ class _MyHomePageState extends State<MyHomePage>
@override @override
Future<void> afterFirstLayout(BuildContext context) async { Future<void> afterFirstLayout(BuildContext context) async {
await GetIt.I.allReady(); await GetIt.I.allReady();
await locator<ServerProvider>().loadLocalData(); await _serverProvider.loadLocalData();
await _serverProvider.refreshData();
await doUpdate(context); await doUpdate(context);
if (!Analysis.enabled) { if (!Analysis.enabled) {
await Analysis.init(); await Analysis.init();

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:toolbox/core/utils/navigator.dart';
import '../../data/model/pkg/upgrade_info.dart'; import '../../data/model/pkg/upgrade_info.dart';
import '../../data/model/server/dist.dart'; import '../../data/model/server/dist.dart';
@@ -51,7 +52,7 @@ class _PkgManagePageState extends State<PkgManagePage>
final si = locator<ServerProvider>().getServer(widget.spi.id); final si = locator<ServerProvider>().getServer(widget.spi.id);
if (si.client == null) { if (si.client == null) {
showSnackBar(context, Text(_s.waitConnection)); showSnackBar(context, Text(_s.waitConnection));
Navigator.of(context).pop(); context.pop();
return; return;
} }
@@ -76,14 +77,14 @@ class _PkgManagePageState extends State<PkgManagePage>
Text(_s.fieldMustNotEmpty), Text(_s.fieldMustNotEmpty),
[ [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(_s.ok), child: Text(_s.ok),
), ),
], ],
); );
return; return;
} }
Navigator.of(context).pop(); context.pop();
} }
Future<String> onPwdRequest() async { Future<String> onPwdRequest() async {
@@ -103,8 +104,8 @@ class _PkgManagePageState extends State<PkgManagePage>
[ [
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(); context.pop();
Navigator.of(context).pop(); context.pop();
}, },
child: Text(_s.cancel)), child: Text(_s.cancel)),
TextButton( TextButton(

View File

@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:toolbox/core/extension/numx.dart'; import 'package:toolbox/core/extension/numx.dart';
import 'package:toolbox/core/utils/misc.dart'; import 'package:toolbox/core/utils/misc.dart';
import 'package:toolbox/core/utils/navigator.dart';
import 'package:toolbox/data/res/misc.dart'; import 'package:toolbox/data/res/misc.dart';
import '../../../core/utils/server.dart'; import '../../../core/utils/server.dart';
@@ -68,7 +69,7 @@ class _PrivateKeyEditPageState extends State<PrivateKeyEditPage>
tooltip: _s.delete, tooltip: _s.delete,
onPressed: () { onPressed: () {
_provider.delInfo(widget.info!); _provider.delInfo(widget.info!);
Navigator.of(context).pop(); context.pop();
}, },
icon: const Icon(Icons.delete)) icon: const Icon(Icons.delete))
: const SizedBox() : const SizedBox()
@@ -176,7 +177,7 @@ class _PrivateKeyEditPageState extends State<PrivateKeyEditPage>
} else { } else {
_provider.addInfo(info); _provider.addInfo(info);
} }
Navigator.of(context).pop(); context.pop();
}, },
child: const Icon(Icons.save), child: const Icon(Icons.save),
), ),

View File

@@ -2,6 +2,7 @@ import 'package:after_layout/after_layout.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:toolbox/core/utils/navigator.dart';
import '../../../core/route.dart'; import '../../../core/route.dart';
import '../../../core/utils/ui.dart'; import '../../../core/utils/ui.dart';
@@ -74,8 +75,8 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
TextButton( TextButton(
onPressed: () { onPressed: () {
_serverProvider.delServer(widget.spi!.id); _serverProvider.delServer(widget.spi!.id);
Navigator.of(context).pop(); context.pop();
Navigator.of(context).pop(); context.pop();
}, },
child: Text( child: Text(
_s.ok, _s.ok,
@@ -83,7 +84,7 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
), ),
), ),
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(_s.cancel), child: Text(_s.cancel),
) )
], ],
@@ -232,11 +233,11 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
Text(_s.sureNoPwd), Text(_s.sureNoPwd),
[ [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(false), onPressed: () => context.pop(false),
child: Text(_s.ok), child: Text(_s.ok),
), ),
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(true), onPressed: () => context.pop(true),
child: Text(_s.cancel), child: Text(_s.cancel),
) )
], ],
@@ -277,7 +278,7 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
_serverProvider.updateServer(widget.spi!, spi); _serverProvider.updateServer(widget.spi!, spi);
} }
Navigator.of(context).pop(); context.pop();
}, },
), ),
); );

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:toolbox/core/utils/navigator.dart';
import '../../../core/route.dart'; import '../../../core/route.dart';
import '../../../core/utils/ui.dart'; import '../../../core/utils/ui.dart';
@@ -11,7 +12,6 @@ import '../../../data/model/server/server.dart';
import '../../../data/model/server/server_private_info.dart'; import '../../../data/model/server/server_private_info.dart';
import '../../../data/model/server/server_status.dart'; import '../../../data/model/server/server_status.dart';
import '../../../data/provider/server.dart'; import '../../../data/provider/server.dart';
import '../../../data/provider/snippet.dart';
import '../../../data/res/color.dart'; import '../../../data/res/color.dart';
import '../../../data/res/menu.dart'; import '../../../data/res/menu.dart';
import '../../../data/res/ui.dart'; import '../../../data/res/ui.dart';
@@ -19,13 +19,11 @@ import '../../../data/res/url.dart';
import '../../../data/store/setting.dart'; import '../../../data/store/setting.dart';
import '../../../locator.dart'; import '../../../locator.dart';
import '../../widget/dropdown_menu.dart'; import '../../widget/dropdown_menu.dart';
import '../../widget/picker.dart';
import '../../widget/round_rect_card.dart'; import '../../widget/round_rect_card.dart';
import '../../widget/url_text.dart'; import '../../widget/url_text.dart';
import '../docker.dart'; import '../docker.dart';
import '../pkg.dart'; import '../pkg.dart';
import '../sftp/view.dart'; import '../sftp/view.dart';
import '../snippet/edit.dart';
import '../ssh.dart'; import '../ssh.dart';
import 'detail.dart'; import 'detail.dart';
import 'edit.dart'; import 'edit.dart';
@@ -118,7 +116,7 @@ class _ServerPageState extends State<ServerPage>
.go(context), .go(context),
child: Padding( child: Padding(
padding: const EdgeInsets.all(13), padding: const EdgeInsets.all(13),
child: _buildRealServerCard(si.status, si.spi.name, si.cs, si.spi), child: _buildRealServerCard(si.status, si.spi.name, si.state, si.spi),
), ),
onTap: () => AppRoute(ServerDetailPage(si.spi.id), 'server detail page') onTap: () => AppRoute(ServerDetailPage(si.spi.id), 'server detail page')
.go(context), .go(context),
@@ -225,7 +223,7 @@ class _ServerPageState extends State<ServerPage>
[ [
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(); context.pop();
AppRoute(SSHPage(spi: spi), 'ssh page').go(context); AppRoute(SSHPage(spi: spi), 'ssh page').go(context);
}, },
child: Text(_s.ok), child: Text(_s.ok),
@@ -266,7 +264,21 @@ class _ServerPageState extends State<ServerPage>
AppRoute(SFTPPage(spi), 'SFTP').go(context); AppRoute(SFTPPage(spi), 'SFTP').go(context);
break; break;
case ServerTabMenuItems.snippet: case ServerTabMenuItems.snippet:
_showSnippetDialog(spi.id); showSnippetDialog(context, _s, (s) async {
final result =
await locator<ServerProvider>().runSnippet(spi.id, s);
showRoundDialog(
context,
_s.result,
Text(result ?? _s.error, style: textSize13),
[
TextButton(
onPressed: () => context.pop(),
child: Text(_s.ok),
)
],
);
});
break; break;
case ServerTabMenuItems.edit: case ServerTabMenuItems.edit:
AppRoute(ServerEditPage(spi: spi), 'Edit server info').go(context); AppRoute(ServerEditPage(spi: spi), 'Edit server info').go(context);
@@ -382,61 +394,6 @@ class _ServerPageState extends State<ServerPage>
); );
} }
void _showSnippetDialog(String id) {
final provider = locator<SnippetProvider>();
if (provider.snippets.isEmpty) {
showRoundDialog(
context,
_s.attention,
Text(_s.noSavedSnippet),
[
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(_s.ok),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
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 _serverProvider.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 @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;

View File

@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_material_color_picker/flutter_material_color_picker.dart'; import 'package:flutter_material_color_picker/flutter_material_color_picker.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:toolbox/data/res/path.dart';
import '../../core/utils/misc.dart'; import '../../core/utils/misc.dart';
import '../../core/utils/platform.dart'; import '../../core/utils/platform.dart';
@@ -15,6 +14,7 @@ import '../../data/provider/app.dart';
import '../../data/provider/server.dart'; import '../../data/provider/server.dart';
import '../../data/res/build_data.dart'; import '../../data/res/build_data.dart';
import '../../data/res/color.dart'; import '../../data/res/color.dart';
import '../../data/res/path.dart';
import '../../data/res/tab.dart'; import '../../data/res/tab.dart';
import '../../data/res/ui.dart'; import '../../data/res/ui.dart';
import '../../data/store/setting.dart'; import '../../data/store/setting.dart';
@@ -61,6 +61,7 @@ class _SettingPageState extends State<SettingPage> {
_nightMode = _setting.themeMode.fetch()!; _nightMode = _setting.themeMode.fetch()!;
_updateInterval = _setting.serverStatusUpdateInterval.fetch()!.toDouble(); _updateInterval = _setting.serverStatusUpdateInterval.fetch()!.toDouble();
_maxRetryCount = _setting.maxRetryCount.fetch()!.toDouble(); _maxRetryCount = _setting.maxRetryCount.fetch()!.toDouble();
_selectedColorValue = _setting.primaryColor.fetch()!;
} }
@override @override
@@ -78,6 +79,9 @@ class _SettingPageState extends State<SettingPage> {
// Server // Server
_buildTitle(_s.server), _buildTitle(_s.server),
_buildServer(), _buildServer(),
// SSH
_buildTitle('SSH'),
_buildSSH(),
const SizedBox(height: 37), const SizedBox(height: 37),
], ],
), ),
@@ -102,11 +106,13 @@ class _SettingPageState extends State<SettingPage> {
_buildAppColorPreview(), _buildAppColorPreview(),
_buildLaunchPage(), _buildLaunchPage(),
_buildCheckUpdate(), _buildCheckUpdate(),
_buildFont(),
]; ];
if (isIOS) { if (isIOS) {
children.add(_buildPushToken()); children.add(_buildPushToken());
} }
if (isAndroid) {
children.add(_buildBgRun());
}
return Column( return Column(
children: children.map((e) => RoundRectCard(e)).toList(), children: children.map((e) => RoundRectCard(e)).toList(),
); );
@@ -117,12 +123,20 @@ class _SettingPageState extends State<SettingPage> {
children: [ children: [
_buildDistLogoSwitch(), _buildDistLogoSwitch(),
_buildUpdateInterval(), _buildUpdateInterval(),
_buildTermTheme(),
_buildMaxRetry(), _buildMaxRetry(),
].map((e) => RoundRectCard(e)).toList(), ].map((e) => RoundRectCard(e)).toList(),
); );
} }
Widget _buildSSH() {
return Column(
children: [
_buildTermTheme(),
_buildFont(),
].map((e) => RoundRectCard(e)).toList(),
);
}
Widget _buildDistLogoSwitch() { Widget _buildDistLogoSwitch() {
return ListTile( return ListTile(
title: Text( title: Text(
@@ -478,11 +492,17 @@ class _SettingPageState extends State<SettingPage> {
Future<void> _pickFontFile() async { Future<void> _pickFontFile() async {
final path = await pickOneFile(); final path = await pickOneFile();
if (path != null) { if (path != null) {
final fontDir_ = await fontDir; // iOS can't copy file to app dir, so we need to use the original path
final fontFile = File(path); if (isIOS) {
final newPath = '${fontDir_.path}/${path.split('/').last}'; _setting.fontPath.put(path);
await fontFile.copy(newPath); } else {
_setting.fontPath.put(newPath); final fontDir_ = await fontDir;
final fontFile = File(path);
final newPath = '${fontDir_.path}/${path.split('/').last}';
await fontFile.copy(newPath);
_setting.fontPath.put(newPath);
}
setState(() {}); setState(() {});
_showRestartSnackbar(); _showRestartSnackbar();
return; return;
@@ -498,4 +518,11 @@ class _SettingPageState extends State<SettingPage> {
() => rebuildAll(context), () => rebuildAll(context),
); );
} }
Widget _buildBgRun() {
return ListTile(
title: Text(_s.bgRun),
trailing: buildSwitch(context, _setting.bgRun),
);
}
} }

View File

@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:toolbox/core/utils/navigator.dart';
import '../../../core/extension/numx.dart'; import '../../../core/extension/numx.dart';
import '../../../core/extension/stringx.dart'; import '../../../core/extension/stringx.dart';
@@ -29,7 +30,7 @@ class _SFTPDownloadedPageState extends State<SFTPDownloadedPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
sftpDownloadDir.then((dir) { sftpDir.then((dir) {
_path = PathWithPrefix(dir.path); _path = PathWithPrefix(dir.path);
_prefixPath = '${dir.path}/'; _prefixPath = '${dir.path}/';
setState(() {}); setState(() {});
@@ -144,21 +145,21 @@ class _SFTPDownloadedPageState extends State<SFTPDownloadedPage> {
leading: const Icon(Icons.delete), leading: const Icon(Icons.delete),
title: Text(_s.delete), title: Text(_s.delete),
onTap: () { onTap: () {
Navigator.of(context).pop(); context.pop();
showRoundDialog( showRoundDialog(
context, context,
_s.sureDelete(fileName), _s.sureDelete(fileName),
const SizedBox(), const SizedBox(),
[ [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(_s.cancel), child: Text(_s.cancel),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
file.deleteSync(); file.deleteSync();
setState(() {}); setState(() {});
Navigator.of(context).pop(); context.pop();
}, },
child: Text(_s.ok), child: Text(_s.ok),
), ),
@@ -177,7 +178,7 @@ class _SFTPDownloadedPageState extends State<SFTPDownloadedPage> {
), ),
[ [
TextButton( TextButton(
onPressed: (() => Navigator.of(context).pop()), onPressed: (() => context.pop()),
child: Text(_s.close), child: Text(_s.close),
) )
], ],

View File

@@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:dartssh2/dartssh2.dart'; import 'package:dartssh2/dartssh2.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:toolbox/core/utils/navigator.dart';
import '../../../core/extension/numx.dart'; import '../../../core/extension/numx.dart';
import '../../../core/extension/stringx.dart'; import '../../../core/extension/stringx.dart';
@@ -18,6 +19,7 @@ import '../../../data/provider/sftp_download.dart';
import '../../../data/res/path.dart'; import '../../../data/res/path.dart';
import '../../../data/store/private_key.dart'; import '../../../data/store/private_key.dart';
import '../../../locator.dart'; import '../../../locator.dart';
import '../../widget/center_loading.dart';
import '../../widget/fade_in.dart'; import '../../widget/fade_in.dart';
import '../../widget/two_line_text.dart'; import '../../widget/two_line_text.dart';
import 'downloading.dart'; import 'downloading.dart';
@@ -113,7 +115,7 @@ class _SFTPPageState extends State<SFTPPage> {
), ),
[ [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(_s.close), child: Text(_s.close),
) )
], ],
@@ -134,14 +136,13 @@ class _SFTPPageState extends State<SFTPPage> {
labelText: _s.path, labelText: _s.path,
hintText: '/', hintText: '/',
), ),
onSubmitted: (value) => onSubmitted: (value) => context.pop(value),
Navigator.of(context).pop(value),
), ),
], ],
), ),
[ [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(_s.cancel), child: Text(_s.cancel),
) )
], ],
@@ -177,7 +178,7 @@ class _SFTPPageState extends State<SFTPPage> {
); );
Widget _buildFileView() { Widget _buildFileView() {
if (_client == null || _si?.cs != ServerState.connected) { if (_client == null || _si?.state != ServerState.connected) {
return centerCircleLoading; return centerCircleLoading;
} }
@@ -279,7 +280,7 @@ class _SFTPPageState extends State<SFTPPage> {
), ),
[ [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(_s.cancel), child: Text(_s.cancel),
) )
], ],
@@ -293,16 +294,14 @@ class _SFTPPageState extends State<SFTPPage> {
Text('${_s.dl2Local(name.filename)}\n${_s.keepForeground}'), Text('${_s.dl2Local(name.filename)}\n${_s.keepForeground}'),
[ [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(_s.cancel), child: Text(_s.cancel),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Navigator.of(context).pop(); context.pop();
final prePath = _status.path!.path; final remotePath = _getRemotePath(name);
final remotePath = final local = '${(await sftpDir).path}$remotePath';
prePath + (prePath.endsWith('/') ? '' : '/') + name.filename;
final local = '${(await sftpDownloadDir).path}$remotePath';
final pubKeyId = widget.spi.pubKeyId; final pubKeyId = widget.spi.pubKeyId;
locator<SftpProvider>().add( locator<SftpProvider>().add(
@@ -316,7 +315,7 @@ class _SFTPPageState extends State<SFTPPage> {
: locator<PrivateKeyStore>().get(pubKeyId).privateKey, : locator<PrivateKeyStore>().get(pubKeyId).privateKey,
); );
Navigator.of(context).pop(); context.pop();
}, },
child: Text(_s.download), child: Text(_s.download),
) )
@@ -325,20 +324,46 @@ class _SFTPPageState extends State<SFTPPage> {
} }
void delete(BuildContext context, SftpName file) { void delete(BuildContext context, SftpName file) {
Navigator.of(context).pop(); context.pop();
final isDir = file.attr.isDirectory;
showRoundDialog( showRoundDialog(
context, context,
_s.attention, _s.attention,
Text(_s.sureDelete(file.filename)), Text(
'${_s.sureDelete(file.filename)}${isDir ? '\n${_s.sureDirEmpty}' : ''}'),
[ [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: const Text('Cancel'), child: Text(_s.cancel),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () async {
_status.client!.remove(file.filename); context.pop();
Navigator.of(context).pop(); showRoundDialog(context, 'Waiting...', centerSizedLoading, [],
barrierDismiss: false);
final remotePath = _getRemotePath(file);
try {
if (file.attr.isDirectory) {
await _status.client!.rmdir(remotePath);
} else {
await _status.client!.remove(remotePath);
}
context.pop();
} catch (e) {
context.pop();
showRoundDialog(
context,
_s.attention,
Text(e.toString()),
[
TextButton(
onPressed: () => context.pop(),
child: Text(_s.ok),
)
],
);
return;
}
listDir(); listDir();
}, },
child: Text( child: Text(
@@ -351,7 +376,7 @@ class _SFTPPageState extends State<SFTPPage> {
} }
void mkdir(BuildContext context) { void mkdir(BuildContext context) {
Navigator.of(context).pop(); context.pop();
final textController = TextEditingController(); final textController = TextEditingController();
showRoundDialog( showRoundDialog(
context, context,
@@ -364,7 +389,7 @@ class _SFTPPageState extends State<SFTPPage> {
), ),
[ [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(_s.cancel), child: Text(_s.cancel),
), ),
TextButton( TextButton(
@@ -376,7 +401,7 @@ class _SFTPPageState extends State<SFTPPage> {
Text(_s.fieldMustNotEmpty), Text(_s.fieldMustNotEmpty),
[ [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(_s.ok), child: Text(_s.ok),
), ),
], ],
@@ -385,7 +410,7 @@ class _SFTPPageState extends State<SFTPPage> {
} }
_status.client! _status.client!
.mkdir('${_status.path!.path}/${textController.text}'); .mkdir('${_status.path!.path}/${textController.text}');
Navigator.of(context).pop(); context.pop();
listDir(); listDir();
}, },
child: Text( child: Text(
@@ -398,7 +423,7 @@ class _SFTPPageState extends State<SFTPPage> {
} }
void newFile(BuildContext context) { void newFile(BuildContext context) {
Navigator.of(context).pop(); context.pop();
final textController = TextEditingController(); final textController = TextEditingController();
showRoundDialog( showRoundDialog(
context, context,
@@ -411,7 +436,7 @@ class _SFTPPageState extends State<SFTPPage> {
), ),
[ [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(_s.cancel), child: Text(_s.cancel),
), ),
TextButton( TextButton(
@@ -423,7 +448,7 @@ class _SFTPPageState extends State<SFTPPage> {
Text(_s.fieldMustNotEmpty), Text(_s.fieldMustNotEmpty),
[ [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(_s.ok), child: Text(_s.ok),
), ),
], ],
@@ -433,7 +458,7 @@ class _SFTPPageState extends State<SFTPPage> {
(await _status.client! (await _status.client!
.open('${_status.path!.path}/${textController.text}')) .open('${_status.path!.path}/${textController.text}'))
.writeBytes(Uint8List(0)); .writeBytes(Uint8List(0));
Navigator.of(context).pop(); context.pop();
listDir(); listDir();
}, },
child: Text( child: Text(
@@ -446,7 +471,7 @@ class _SFTPPageState extends State<SFTPPage> {
} }
void rename(BuildContext context, SftpName file) { void rename(BuildContext context, SftpName file) {
Navigator.of(context).pop(); context.pop();
final textController = TextEditingController(); final textController = TextEditingController();
showRoundDialog( showRoundDialog(
context, context,
@@ -458,9 +483,7 @@ class _SFTPPageState extends State<SFTPPage> {
), ),
), ),
[ [
TextButton( TextButton(onPressed: () => context.pop(), child: Text(_s.cancel)),
onPressed: () => Navigator.of(context).pop(),
child: Text(_s.cancel)),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
if (textController.text == '') { if (textController.text == '') {
@@ -470,7 +493,7 @@ class _SFTPPageState extends State<SFTPPage> {
Text(_s.fieldMustNotEmpty), Text(_s.fieldMustNotEmpty),
[ [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(_s.ok), child: Text(_s.ok),
), ),
], ],
@@ -478,7 +501,7 @@ class _SFTPPageState extends State<SFTPPage> {
return; return;
} }
await _status.client!.rename(file.filename, textController.text); await _status.client!.rename(file.filename, textController.text);
Navigator.of(context).pop(); context.pop();
listDir(); listDir();
}, },
child: Text( child: Text(
@@ -490,6 +513,11 @@ class _SFTPPageState extends State<SFTPPage> {
); );
} }
String _getRemotePath(SftpName name) {
final prePath = _status.path!.path;
return prePath + (prePath.endsWith('/') ? '' : '/') + name.filename;
}
Future<void> listDir({String? path, SSHClient? client}) async { Future<void> listDir({String? path, SSHClient? client}) async {
if (_status.isBusy) { if (_status.isBusy) {
return; return;
@@ -517,7 +545,7 @@ class _SFTPPageState extends State<SFTPPage> {
Text(e.toString()), Text(e.toString()),
[ [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(_s.ok), child: Text(_s.ok),
) )
], ],

View File

@@ -1,6 +1,7 @@
import 'package:after_layout/after_layout.dart'; import 'package:after_layout/after_layout.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:toolbox/core/utils/navigator.dart';
import '../../../core/utils/ui.dart'; import '../../../core/utils/ui.dart';
import '../../../data/model/server/snippet.dart'; import '../../../data/model/server/snippet.dart';
@@ -49,7 +50,7 @@ class _SnippetEditPageState extends State<SnippetEditPage>
? IconButton( ? IconButton(
onPressed: () { onPressed: () {
_provider.del(widget.snippet!); _provider.del(widget.snippet!);
Navigator.of(context).pop(); context.pop();
}, },
tooltip: _s.delete, tooltip: _s.delete,
icon: const Icon(Icons.delete)) icon: const Icon(Icons.delete))
@@ -93,7 +94,7 @@ class _SnippetEditPageState extends State<SnippetEditPage>
} else { } else {
_provider.add(snippet); _provider.add(snippet);
} }
Navigator.of(context).pop(); context.pop();
}, },
), ),
); );

View File

@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:toolbox/core/utils/navigator.dart';
import 'package:xterm/xterm.dart' hide TerminalColors; import 'package:xterm/xterm.dart' hide TerminalColors;
import '../../core/utils/platform.dart'; import '../../core/utils/platform.dart';
@@ -42,6 +43,9 @@ class _SSHPageState extends State<SSHPage> {
late TextStyle _menuTextStyle; late TextStyle _menuTextStyle;
late TerminalColors _termColors; late TerminalColors _termColors;
late S _s; late S _s;
late TerminalStyle _terminalStyle;
late TerminalUITheme _termUITheme;
late TerminalTheme _terminalTheme;
var _isDark = false; var _isDark = false;
@@ -50,6 +54,8 @@ class _SSHPageState extends State<SSHPage> {
super.initState(); super.initState();
final termColorIdx = _setting.termColorIdx.fetch()!; final termColorIdx = _setting.termColorIdx.fetch()!;
_termColors = TerminalColorsPlatform.values[termColorIdx].colors; _termColors = TerminalColorsPlatform.values[termColorIdx].colors;
final ts = TextStyle(fontFamily: getFileName(_setting.fontPath.fetch()));
_terminalStyle = TerminalStyle.fromTextStyle(ts);
initTerminal(); initTerminal();
} }
@@ -60,6 +66,8 @@ class _SSHPageState extends State<SSHPage> {
_media = MediaQuery.of(context); _media = MediaQuery.of(context);
_menuTextStyle = TextStyle(color: contentColor.resolve(context)); _menuTextStyle = TextStyle(color: contentColor.resolve(context));
_s = S.of(context)!; _s = S.of(context)!;
_termUITheme = _isDark ? termDarkTheme : termLightTheme;
_terminalTheme = _termUITheme.toTerminalTheme(_termColors);
} }
@override @override
@@ -112,7 +120,7 @@ class _SSHPageState extends State<SSHPage> {
await session.done; await session.done;
if (mounted) { if (mounted) {
Navigator.of(context).pop(); context.pop();
} }
} }
@@ -125,11 +133,10 @@ class _SSHPageState extends State<SSHPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final termTheme = _isDark ? termDarkTheme : termLightTheme;
Widget child = Scaffold( Widget child = Scaffold(
backgroundColor: termTheme.background, backgroundColor: _termUITheme.background,
body: _buildBody(termTheme.toTerminalTheme(_termColors)), body: _buildBody(),
bottomNavigationBar: _buildBottom(termTheme.background), bottomNavigationBar: _buildBottom(),
); );
if (isIOS) { if (isIOS) {
child = AnnotatedRegion( child = AnnotatedRegion(
@@ -140,7 +147,7 @@ class _SSHPageState extends State<SSHPage> {
return child; return child;
} }
Widget _buildBody(TerminalTheme termTheme) { Widget _buildBody() {
return SizedBox( return SizedBox(
height: _media.size.height - height: _media.size.height -
_virtualKeyboardHeight - _virtualKeyboardHeight -
@@ -150,9 +157,8 @@ class _SSHPageState extends State<SSHPage> {
_terminal, _terminal,
controller: _terminalController, controller: _terminalController,
keyboardType: TextInputType.visiblePassword, keyboardType: TextInputType.visiblePassword,
textStyle: TerminalStyle.fromTextStyle( textStyle: _terminalStyle,
TextStyle(fontFamily: getFileName(_setting.fontPath.fetch()))), theme: _terminalTheme,
theme: termTheme,
deleteDetection: isIOS, deleteDetection: isIOS,
onTapUp: _onTapUp, onTapUp: _onTapUp,
autoFocus: true, autoFocus: true,
@@ -161,14 +167,14 @@ class _SSHPageState extends State<SSHPage> {
); );
} }
Widget _buildBottom(Color bgColor) { Widget _buildBottom() {
return SafeArea( return SafeArea(
child: AnimatedPadding( child: AnimatedPadding(
padding: _media.viewInsets, padding: _media.viewInsets,
duration: const Duration(milliseconds: 23), duration: const Duration(milliseconds: 23),
curve: Curves.fastOutSlowIn, curve: Curves.fastOutSlowIn,
child: Container( child: Container(
color: bgColor, color: _termUITheme.background,
height: _virtualKeyboardHeight, height: _virtualKeyboardHeight,
child: Consumer<VirtualKeyboard>( child: Consumer<VirtualKeyboard>(
builder: (_, __, ___) => _buildVirtualKey(), builder: (_, __, ___) => _buildVirtualKey(),
@@ -218,7 +224,7 @@ class _SSHPageState extends State<SSHPage> {
item.text, item.text,
style: TextStyle( style: TextStyle(
color: selected ? primaryColor : null, color: selected ? primaryColor : null,
fontSize: 17, fontSize: 15,
), ),
); );
@@ -274,6 +280,12 @@ class _SSHPageState extends State<SSHPage> {
case VirtualKeyFunc.copy: case VirtualKeyFunc.copy:
copy(terminalSelected); copy(terminalSelected);
break; break;
case VirtualKeyFunc.snippet:
showSnippetDialog(context, _s, (s) {
_terminal.textInput(s.script);
_terminal.keyInput(TerminalKey.enter);
});
break;
} }
} }
@@ -304,12 +316,12 @@ class _SSHPageState extends State<SSHPage> {
// context: context, // context: context,
// contextMenuBuilder: (context) { // contextMenuBuilder: (context) {
// return TextSelectionToolbar( // return TextSelectionToolbar(
// anchorAbove: detail.globalPosition, // anchorAbove: details.globalPosition,
// anchorBelow: detail.globalPosition, // anchorBelow: details.globalPosition,
// children: [ // children: [
// TextButton( // TextButton(
// child: Text( // child: Text(
// 'Paste', // _s.paste,
// style: _menuTextStyle, // style: _menuTextStyle,
// ), // ),
// onPressed: () async { // onPressed: () async {

View File

@@ -25,10 +25,11 @@ Widget buildPicker(List<Widget> items, Function(int idx) onSelected) {
onSelectedItemChanged: (idx) => onSelected(idx), onSelectedItemChanged: (idx) => onSelected(idx),
physics: const FixedExtentScrollPhysics(), physics: const FixedExtentScrollPhysics(),
childDelegate: ListWheelChildBuilderDelegate( childDelegate: ListWheelChildBuilderDelegate(
builder: (context, index) => Center( builder: (context, index) => Center(
child: items[index], child: items[index],
), ),
childCount: items.length), childCount: items.length,
),
) )
], ],
), ),

View File

@@ -5,17 +5,15 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
const appName = 'ServerBox'; const appName = 'ServerBox';
const buildDataFilePath = 'lib/data/res/build_data.dart'; const buildDataFilePath = 'lib/data/res/build_data.dart';
const apkPath = 'build/app/outputs/flutter-apk/app-release.apk'; const apkPath = 'build/app/outputs/flutter-apk/app-release.apk';
const xcarchivePath = 'build/ios/archive/Runner.xcarchive'; const xcarchivePath = 'build/ios/archive/Runner.xcarchive';
const appleXCConfigPath = '/Runner.xcodeproj/project.pbxproj'; const appleXCConfigPath = 'Runner.xcodeproj/project.pbxproj';
var regAppleProjectVer = RegExp(r'CURRENT_PROJECT_VERSION = .+;'); var regAppleProjectVer = RegExp(r'CURRENT_PROJECT_VERSION = .+;');
var regAppleMarketVer = RegExp(r'MARKETING_VERSION = .+'); var regAppleMarketVer = RegExp(r'MARKETING_VERSION = .+');
const skslFileSuffix = '.sksl.json';
const buildFuncs = { const buildFuncs = {
'ios': flutterBuildIOS, 'ios': flutterBuildIOS,
'android': flutterBuildAndroid, 'android': flutterBuildAndroid,
@@ -62,7 +60,8 @@ Future<int> getGitModificationCount() async {
Future<String> getFlutterVersion() async { Future<String> getFlutterVersion() async {
final result = await fvmRun(['flutter', '--version']); final result = await fvmRun(['flutter', '--version']);
return (result.stdout as String); final stdout = result.stdout as String;
return stdout.split('\n')[0].split('')[0].split(' ')[1].trim();
} }
Future<Map<String, dynamic>> getBuildData() async { Future<Map<String, dynamic>> getBuildData() async {
@@ -109,8 +108,7 @@ Future<void> flutterBuild(
'build', 'build',
buildType, buildType,
]; ];
// No sksl cache for macos final skslPath = '$buildType.sksl.json';
final skslPath = '$buildType$skslFileSuffix';
if (await File(skslPath).exists()) { if (await File(skslPath).exists()) {
args.add('--bundle-sksl-path=$skslPath'); args.add('--bundle-sksl-path=$skslPath');
} }
@@ -164,7 +162,7 @@ Future<void> flutterBuildAndroid() async {
Future<void> changeAppleVersion() async { Future<void> changeAppleVersion() async {
for (final path in ['ios', 'macos']) { for (final path in ['ios', 'macos']) {
final file = File(path + appleXCConfigPath); final file = File('$path/$appleXCConfigPath');
final contents = await file.readAsString(); final contents = await file.readAsString();
final newContents = contents final newContents = contents
.replaceAll(regAppleMarketVer, 'MARKETING_VERSION = 1.0.$build;') .replaceAll(regAppleMarketVer, 'MARKETING_VERSION = 1.0.$build;')
@@ -198,6 +196,8 @@ void main(List<String> args) async {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
await dartFormat(); await dartFormat();
await getGitCommitCount(); await getGitCommitCount();
// always change version to avoid dismatch version between different
// platforms
await changeAppleVersion(); await changeAppleVersion();
await updateBuildData(); await updateBuildData();
if (args.length > 1) { if (args.length > 1) {

View File

@@ -746,10 +746,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: share_plus name: share_plus
sha256: "8c6892037b1824e2d7e8f59d54b3105932899008642e6372e5079c6939b4b625" sha256: "692261968a494e47323dcc8bc66d8d52e81bc27cb4b808e4e8d7e8079d4cc01a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.1" version: "6.3.2"
share_plus_platform_interface: share_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:

View File

@@ -47,15 +47,20 @@ dependencies:
url: https://github.com/lollipopkit/circle_chart url: https://github.com/lollipopkit/circle_chart
ref: main ref: main
# path: ../circle_chart # path: ../circle_chart
r_upgrade: ^0.3.6
# TODO: Update this :)
r_upgrade: 0.3.8+2
path_provider: ^2.0.9 path_provider: ^2.0.9
easy_isolate: ^1.3.0 easy_isolate: ^1.3.0
share_plus: ^6.3.1 share_plus: ^6.3.2
intl: ^0.17.0 intl: ^0.17.0
share_plus_web: ^3.1.0 share_plus_web: ^3.1.0
# xterm: ^3.4.1 # xterm: ^3.4.1
xterm: xterm:
path: ../xterm.dart path: ../xterm.dart
# git:
# ref: master
# url: https://github.com/lollipopkit/xterm.dart
file_picker: ^5.2.5 file_picker: ^5.2.5
plain_notification_token: ^0.0.4 plain_notification_token: ^0.0.4

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 392 KiB