Compare commits

...

34 Commits

Author SHA1 Message Date
lollipopkit🏳️‍⚧️
229983d82e chore: bump version 2024-10-17 14:06:45 +08:00
Mased
4928ca600d [Translation] some Russian fixes (#604) 2024-10-07 21:04:36 +08:00
lollipopkit🏳️‍⚧️
89ec2d94d6 opt.: virt keys loading 2024-10-05 10:18:00 +08:00
lollipopkit🏳️‍⚧️
393c3e6388 Merge branch 'main' of github.com:lollipopkit/flutter_server_box 2024-10-05 10:00:41 +08:00
Gitro
dee458e926 new: material you icon (#599) 2024-10-04 12:19:10 +08:00
lollipopkit🏳️‍⚧️
f89228db40 opt.: ssh page 2024-09-28 17:09:35 +08:00
lollipopkit🏳️‍⚧️
3b6fb6194b opt.: more virtual keys (#596) 2024-09-28 14:40:22 +08:00
lollipopkit🏳️‍⚧️
02444fc2f0 new: ios18 dark app icon (#594)
Fixes #593
2024-09-25 19:16:14 +08:00
lollipopkit🏳️‍⚧️
aef317a140 opt.: dismiss notification if no ssh conn (#592) 2024-09-24 22:01:35 +08:00
lollipopkit🏳️‍⚧️
47aedb2f2e fix: rename file 2024-09-22 16:06:09 +08:00
lollipopkit🏳️‍⚧️
eab06abcaf opt. 2024-09-21 23:12:15 +08:00
lollipopkit🏳️‍⚧️
c062c12a0e opt.: redesigned settings page (#587) 2024-09-21 22:37:42 +08:00
lollipopkit🏳️‍⚧️
d7669c94b8 opt.: spi with same id (#585) 2024-09-21 11:54:56 +08:00
lollipopkit🏳️‍⚧️
50af289574 migrate: fl_lib 2024-09-21 11:01:41 +08:00
lollipopkit🏳️‍⚧️
90b88ed795 opt.: sync immediately after changes (#577) 2024-09-14 17:08:51 +08:00
CakesTwix
d611fdcd50 l10n: Added Ukrainian lang (#574) 2024-09-14 14:48:23 +08:00
lollipopkit🏳️‍⚧️
db9b2dd818 fix: snippet fmt (#570) 2024-09-01 21:07:32 +08:00
lollipopkit🏳️‍⚧️
edb49ead67 opt.: split webdav & other settings (#569) 2024-08-31 21:45:09 +08:00
lollipopkit🏳️‍⚧️
7f0dc656b8 Merge pull request #568 from lollipopkit/lollipopkit/issue564 2024-08-31 19:36:50 +08:00
lollipopkit🏳️‍⚧️
b33d0bbc3e opt.: back btn on scan page
Fixes #564
2024-08-31 19:33:57 +08:00
lollipopkit🏳️‍⚧️
7d0ea8a58b Merge branch 'android_service' 2024-08-31 19:28:04 +08:00
Noo6
c18732d8f3 fix: backup on windows (#563) 2024-08-30 22:44:08 +08:00
Shin
157af0a354 l10n: fixed Japanese translations. (#558) 2024-08-30 11:35:13 +08:00
lollipopkit🏳️‍⚧️
2d9dc044f9 new: custom foreground service on Android (#556) 2024-08-28 16:42:09 +08:00
lollipopkit🏳️‍⚧️
479250c207 init 2024-08-28 13:44:54 +08:00
Noo6
aef7ec911f opt: ssh tab page 2024-08-28 13:18:21 +08:00
lollipopkit🏳️‍⚧️
4f9ee7781f fix: ssh tab page UI (#555) 2024-08-27 17:20:47 +08:00
lollipopkit🏳️‍⚧️
eb83d05c81 fix: ssh alter url (#554) 2024-08-27 15:22:26 +08:00
lollipopkit🏳️‍⚧️
329fd33b69 fix: lang switch (#553) 2024-08-27 14:36:00 +08:00
lollipopkit🏳️‍⚧️
931c5f0bf6 opt.: alterUrl (#550) 2024-08-25 22:52:47 +08:00
lollipopkit🏳️‍⚧️
bcbf1fbc17 fix: fdroid build (#548) 2024-08-25 22:06:55 +08:00
Noo6
3e7315dac6 fix: ssh page displays the CustomAppBar on desktop 2024-08-18 20:04:39 +08:00
Noo6
4cecfdf7a8 opt: ssh card text overflow 2024-08-18 13:57:20 +08:00
Noo6
0346821cf5 opt: windows and linux drag area 2024-08-18 13:27:16 +08:00
119 changed files with 2269 additions and 1765 deletions

View File

@@ -19,7 +19,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: '3.24.0'
flutter-version: '3.24.1'
- uses: actions/setup-java@v4
with:
distribution: 'zulu'

View File

@@ -40,7 +40,7 @@ Please only download pkgs from the source that **you trust**!
## 🔖 Feature
- `Status chart` (CPU, Sensors, GPU...), `SSH` Term, `SFTP`, `Docker & Process & Systemd`...
- Platform specific: `Bio auth``Msg push``Home widget``watchOS App`...
- English, 简体中文; Deutsch [@its-tom](https://github.com/its-tom), 繁體中文 [@kalashnikov](https://github.com/kalashnikov), Indonesian [@azkadev](https://github.com/azkadev), Français [@FrancXPT](https://github.com/FrancXPT), Dutch [@QazCetelic](https://github.com/QazCetelic), Türkçe [@mikropsoft](https://github.com/mikropsoft); Español, Русский язык, Português, 日本語 (Generated by GPT)
- English, 简体中文; Deutsch [@its-tom](https://github.com/its-tom), 繁體中文 [@kalashnikov](https://github.com/kalashnikov), Indonesian [@azkadev](https://github.com/azkadev), Français [@FrancXPT](https://github.com/FrancXPT), Dutch [@QazCetelic](https://github.com/QazCetelic), Türkçe [@mikropsoft](https://github.com/mikropsoft), Українська мова [@CakesTwix](https://github.com/CakesTwix); Español, Русский язык, Português, 日本語 (Generated by GPT)
## 🆘 Help

View File

@@ -43,7 +43,7 @@ Linux / Windows | [GitHub](https://github.com/lollipopkit/flutter_server_box/rel
- 本地化
- English, 简体中文
- Español, Русский язык, Português, 日本語 (Generated by GPT)
- Deutsch [@its-tom](https://github.com/its-tom), 繁體中文 [@kalashnikov](https://github.com/kalashnikov), Indonesian [@azkadev](https://github.com/azkadev), Français [@FrancXPT](https://github.com/FrancXPT), Dutch [@QazCetelic](https://github.com/QazCetelic), Türkçe [@mikropsoft](https://github.com/mikropsoft);
- Deutsch [@its-tom](https://github.com/its-tom), 繁體中文 [@kalashnikov](https://github.com/kalashnikov), Indonesian [@azkadev](https://github.com/azkadev), Français [@FrancXPT](https://github.com/FrancXPT), Dutch [@QazCetelic](https://github.com/QazCetelic), Türkçe [@mikropsoft](https://github.com/mikropsoft), Українська мова [@CakesTwix](https://github.com/CakesTwix);
- 感谢贡献者们!

View File

@@ -53,7 +53,6 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "tech.lolli.toolbox"
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.

View File

@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
@@ -15,7 +16,8 @@
android:icon="@mipmap/ic_launcher"
android:allowBackup="true"
android:hasFragileUserData="true"
android:restoreAnyVersion="true">
android:restoreAnyVersion="true"
tools:targetApi="q">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -29,12 +31,12 @@
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
@@ -43,11 +45,6 @@
android:name="flutterEmbedding"
android:value="2" />
<service
android:name="id.flutter.flutter_background_service.BackgroundService"
android:foregroundServiceType="dataSync"
/>
<receiver
android:name=".widget.HomeWidget"
android:exported="false"
@@ -67,7 +64,12 @@
android:resource="@xml/home_widget" />
</receiver>
<service android:name=".KeepAliveService"/>
<service
android:name=".ForegroundService"
android:enabled="true"
android:foregroundServiceType="dataSync"
android:exported="false" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility?hl=en and
@@ -76,8 +78,8 @@
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
</manifest>

View File

@@ -0,0 +1,88 @@
package tech.lolli.toolbox
import android.app.*
import android.content.Intent
import android.os.Build
import android.os.IBinder
class ForegroundService : Service() {
private val chanId = "ForegroundServiceChannel"
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
"ACTION_STOP_FOREGROUND" -> {
stopForegroundService()
return START_NOT_STICKY
}
else -> {
val notification = createNotification()
startForeground(1, notification)
return START_STICKY
}
}
}
override fun onBind(intent: Intent): IBinder? {
return null
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val serviceChannel = NotificationChannel(
chanId,
chanId,
NotificationManager.IMPORTANCE_DEFAULT
)
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(serviceChannel)
}
}
private fun createNotification(): Notification {
val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this,
0,
notificationIntent,
PendingIntent.FLAG_IMMUTABLE
)
val deleteIntent = Intent(this, ForegroundService::class.java).apply {
action = "ACTION_STOP_FOREGROUND"
}
val deletePendingIntent = PendingIntent.getService(
this,
0,
deleteIntent,
PendingIntent.FLAG_IMMUTABLE
)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, chanId)
.setContentTitle("Server Box")
.setContentText("Open the app")
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent)
.addAction(android.R.drawable.ic_delete, "Stop", deletePendingIntent)
.build()
} else {
Notification.Builder(this)
.setContentTitle("Server Box")
.setContentText("Open the app")
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent)
.addAction(android.R.drawable.ic_delete, "Stop", deletePendingIntent)
.build()
}
}
fun stopForegroundService() {
stopForeground(true)
stopSelf()
}
}

View File

@@ -1,18 +0,0 @@
package tech.lolli.toolbox
import android.app.Service
import android.content.Intent
import android.os.IBinder
import org.jetbrains.annotations.Nullable
class KeepAliveService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY
}
@Nullable
override fun onBind(intent: Intent?): IBinder? {
return null
}
}

View File

@@ -1,6 +1,11 @@
package tech.lolli.toolbox
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.Manifest
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
@@ -18,8 +23,18 @@ class MainActivity: FlutterFragmentActivity() {
result.success(null)
}
"startService" -> {
val intent = Intent(this@MainActivity, KeepAliveService::class.java)
startService(intent)
reqPerm()
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
}
"stopService" -> {
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
stopService(serviceIntent)
result.success(null)
}
else -> {
result.notImplemented()
@@ -28,4 +43,16 @@ class MainActivity: FlutterFragmentActivity() {
}
}
}
private fun reqPerm() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
123,
)
}
}
}

View File

@@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -302,7 +302,6 @@
E33A3E4A2A626DD0009744AB /* Embed Foundation Extensions */,
E39515D52AB5AD64003602C1 /* Embed Watch Content */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
955896919A10AA2BEC131F36 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -452,23 +451,6 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
955896919A10AA2BEC131F36 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -690,7 +672,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1070;
CURRENT_PROJECT_VERSION = 1104;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
@@ -700,7 +682,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1070;
MARKETING_VERSION = 1.0.1104;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -826,7 +808,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1070;
CURRENT_PROJECT_VERSION = 1104;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
@@ -836,7 +818,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1070;
MARKETING_VERSION = 1.0.1104;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -854,7 +836,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1070;
CURRENT_PROJECT_VERSION = 1104;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
@@ -864,7 +846,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1070;
MARKETING_VERSION = 1.0.1104;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -885,7 +867,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1070;
CURRENT_PROJECT_VERSION = 1104;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -898,7 +880,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1070;
MARKETING_VERSION = 1.0.1104;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
@@ -924,7 +906,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1070;
CURRENT_PROJECT_VERSION = 1104;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -937,7 +919,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1070;
MARKETING_VERSION = 1.0.1104;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -960,7 +942,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1070;
CURRENT_PROJECT_VERSION = 1104;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -973,7 +955,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1070;
MARKETING_VERSION = 1.0.1104;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -996,7 +978,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1070;
CURRENT_PROJECT_VERSION = 1104;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1008,7 +990,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1070;
MARKETING_VERSION = 1.0.1104;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
@@ -1037,7 +1019,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1070;
CURRENT_PROJECT_VERSION = 1104;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1049,7 +1031,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1070;
MARKETING_VERSION = 1.0.1104;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
PRODUCT_NAME = ServerBox;
@@ -1075,7 +1057,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1070;
CURRENT_PROJECT_VERSION = 1104;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1087,7 +1069,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1070;
MARKETING_VERSION = 1.0.1104;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
PRODUCT_NAME = ServerBox;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -1 +1,37 @@
{"images":[{"scale":"3x","idiom":"universal","filename":"AppIcon-29.0x29.0@3x.png","size":"29x29","platform":"ios"},{"scale":"2x","idiom":"universal","filename":"AppIcon-29.0x29.0@2x.png","size":"29x29","platform":"ios"},{"scale":"3x","idiom":"universal","filename":"AppIcon-64.0x64.0@3x.png","size":"64x64","platform":"ios"},{"scale":"2x","idiom":"universal","filename":"AppIcon-20.0x20.0@2x.png","size":"20x20","platform":"ios"},{"scale":"2x","idiom":"universal","filename":"AppIcon-60.0x60.0@2x.png","size":"60x60","platform":"ios"},{"scale":"3x","idiom":"universal","filename":"AppIcon-40.0x40.0@3x.png","size":"40x40","platform":"ios"},{"scale":"2x","idiom":"universal","filename":"AppIcon-76.0x76.0@2x.png","size":"76x76","platform":"ios"},{"scale":"3x","idiom":"universal","filename":"AppIcon-38.0x38.0@3x.png","size":"38x38","platform":"ios"},{"scale":"2x","idiom":"universal","filename":"AppIcon-68.0x68.0@2x.png","size":"68x68","platform":"ios"},{"scale":"1x","idiom":"universal","filename":"AppIcon-1024.0x1024.0@1x.png","size":"1024x1024","platform":"ios"},{"scale":"2x","idiom":"universal","filename":"AppIcon-64.0x64.0@2x.png","size":"64x64","platform":"ios"},{"scale":"2x","idiom":"universal","filename":"AppIcon-40.0x40.0@2x.png","size":"40x40","platform":"ios"},{"scale":"2x","idiom":"universal","filename":"AppIcon-83.5x83.5@2x.png","size":"83.5x83.5","platform":"ios"},{"scale":"3x","idiom":"universal","filename":"AppIcon-20.0x20.0@3x.png","size":"20x20","platform":"ios"},{"scale":"2x","idiom":"universal","filename":"AppIcon-38.0x38.0@2x.png","size":"38x38","platform":"ios"},{"scale":"3x","idiom":"universal","filename":"AppIcon-60.0x60.0@3x.png","size":"60x60","platform":"ios"}],"info":{"version":1,"author":"appicon"}}
{
"images" : [
{
"filename" : "Icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "icon-1024 1.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

View File

@@ -74,6 +74,7 @@ class MyApp extends StatelessWidget {
final locale = Stores.setting.locale.fetch().toLocale;
return MaterialApp(
key: ValueKey(locale),
locale: locale,
localizationsDelegates: const [
LibLocalizations.delegate,
@@ -86,19 +87,21 @@ class MyApp extends StatelessWidget {
themeMode: themeMode,
theme: light.fixWindowsFont,
darkTheme: (tMode < 3 ? dark : dark.toAmoled).fixWindowsFont,
home: Builder(
builder: (context) {
context.setLibL10n();
final appL10n = AppLocalizations.of(context);
if (appL10n != null) l10n = appL10n;
home: VirtualWindowFrame(
child: Builder(
builder: (context) {
context.setLibL10n();
final appL10n = AppLocalizations.of(context);
if (appL10n != null) l10n = appL10n;
final intros = _IntroPage.builders;
if (intros.isNotEmpty) {
return _IntroPage(intros);
}
final intros = _IntroPage.builders;
if (intros.isNotEmpty) {
return _IntroPage(intros);
}
return const HomePage();
},
return const HomePage();
},
),
),
);
}

View File

@@ -11,4 +11,8 @@ abstract final class BgRunMC {
static void startService() {
_channel.invokeMethod('startService');
}
static void stopService() {
_channel.invokeMethod('stopService');
}
}

View File

@@ -1,5 +0,0 @@
import 'package:server_box/data/res/build_data.dart';
extension BuildDataX on BuildData {
static const versionStr = 'v1.0.${BuildData.build}';
}

View File

@@ -3,13 +3,11 @@ import 'package:flutter/material.dart';
import 'package:server_box/data/model/server/private_key_info.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/view/page/backup.dart';
import 'package:server_box/view/page/container.dart';
import 'package:server_box/view/page/home/home.dart';
import 'package:server_box/view/page/iperf.dart';
import 'package:server_box/view/page/ping.dart';
import 'package:server_box/view/page/private_key/edit.dart';
import 'package:server_box/view/page/private_key/list.dart';
import 'package:server_box/view/page/pve.dart';
import 'package:server_box/view/page/server/detail/view.dart';
import 'package:server_box/view/page/setting/platform/android.dart';
@@ -18,18 +16,12 @@ import 'package:server_box/view/page/setting/seq/srv_func_seq.dart';
import 'package:server_box/view/page/snippet/result.dart';
import 'package:server_box/view/page/ssh/page.dart';
import 'package:server_box/view/page/setting/seq/virt_key.dart';
import 'package:server_box/view/page/storage/local.dart';
import 'package:server_box/data/model/server/snippet.dart';
import 'package:server_box/view/page/editor.dart';
import 'package:server_box/view/page/process.dart';
import 'package:server_box/view/page/server/edit.dart';
import 'package:server_box/view/page/server/tab.dart';
import 'package:server_box/view/page/setting/entry.dart';
import 'package:server_box/view/page/setting/seq/srv_detail_seq.dart';
import 'package:server_box/view/page/setting/seq/srv_seq.dart';
import 'package:server_box/view/page/snippet/edit.dart';
import 'package:server_box/view/page/snippet/list.dart';
import 'package:server_box/view/page/storage/sftp.dart';
import 'package:server_box/view/page/storage/sftp_mission.dart';
@@ -66,13 +58,6 @@ class AppRoutes {
return AppRoutes(ServerPage(key: key), 'server_tab');
}
static AppRoutes serverEdit({Key? key, Spi? spi}) {
return AppRoutes(
ServerEditPage(spi: spi),
'server_${spi == null ? 'add' : 'edit'}',
);
}
static AppRoutes keyEdit({Key? key, PrivateKeyInfo? pki}) {
return AppRoutes(
PrivateKeyEditPage(pki: pki),
@@ -80,10 +65,6 @@ class AppRoutes {
);
}
static AppRoutes keyList({Key? key}) {
return AppRoutes(PrivateKeysListPage(key: key), 'key_detail');
}
static AppRoutes snippetEdit({Key? key, Snippet? snippet}) {
return AppRoutes(
SnippetEditPage(snippet: snippet),
@@ -91,10 +72,6 @@ class AppRoutes {
);
}
static AppRoutes snippetList({Key? key}) {
return AppRoutes(SnippetListPage(key: key), 'snippet_detail');
}
static AppRoutes ssh({
Key? key,
required Spi spi,
@@ -116,17 +93,6 @@ class AppRoutes {
return AppRoutes(SSHVirtKeySettingPage(key: key), 'ssh_virt_key_setting');
}
static AppRoutes localStorage(
{Key? key, bool isPickFile = false, String? initDir}) {
return AppRoutes(
LocalStoragePage(
key: key,
isPickFile: isPickFile,
initDir: initDir,
),
'local_storage');
}
static AppRoutes sftpMission({Key? key}) {
return AppRoutes(SftpMissionPage(key: key), 'sftp_mission');
}
@@ -143,34 +109,10 @@ class AppRoutes {
'sftp');
}
static AppRoutes backup({Key? key}) {
return AppRoutes(BackupPage(key: key), 'backup');
}
static AppRoutes docker({Key? key, required Spi spi}) {
return AppRoutes(ContainerPage(key: key, spi: spi), 'docker');
}
/// - Pop true if the text is changed & [path] is not null
/// - Pop text if [path] is null
static AppRoutes editor({
Key? key,
String? path,
String? text,
String? langCode,
String? title,
}) {
return AppRoutes(
EditorPage(
key: key,
path: path,
text: text,
langCode: langCode,
title: title,
),
'editor');
}
// static AppRoutes fullscreen({Key? key}) {
// return AppRoutes(FullScreenPage(key: key), 'fullscreen');
// }
@@ -187,10 +129,6 @@ class AppRoutes {
return AppRoutes(ProcessPage(key: key, spi: spi), 'process');
}
static AppRoutes settings({Key? key}) {
return AppRoutes(SettingPage(key: key), 'setting');
}
static AppRoutes serverOrder({Key? key}) {
return AppRoutes(ServerOrderPage(key: key), 'server_order');
}

40
lib/core/sync.dart Normal file
View File

@@ -0,0 +1,40 @@
import 'dart:io';
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/model/app/backup.dart';
import 'package:server_box/data/store/no_backup.dart';
const bakSync = BakSyncer._();
final class BakSyncer extends SyncIface<Backup> {
const BakSyncer._() : super();
@override
Future<void> saveToFile() => Backup.backup();
@override
Future<Backup> fromFile(String path) async {
final content = await File(path).readAsString();
return Backup.fromJsonString(content);
}
@override
Future<RemoteStorage?> get remoteStorage async {
if (isMacOS || isIOS) await icloud.init('iCloud.tech.lolli.serverbox');
final settings = NoBackupStore.instance;
await webdav.init(WebdavInitArgs(
url: settings.webdavUrl.fetch(),
user: settings.webdavUser.fetch(),
pwd: settings.webdavPwd.fetch(),
prefix: 'serverbox/',
));
final icloudEnabled = settings.icloudSync.fetch();
if (icloudEnabled) return icloud;
final webdavEnabled = settings.webdavSync.fetch();
if (webdavEnabled) return webdav;
return null;
}
}

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/foundation.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/res/store.dart';
@@ -62,6 +63,8 @@ Future<SSHClient> genClient(
}) async {
onStatus?.call(GenSSHClientStatus.socket);
String? alterUser;
final socket = await () async {
// Proxy
final jumpSpi_ = () {
@@ -91,15 +94,18 @@ Future<SSHClient> genClient(
timeout: timeout,
);
} catch (e) {
Loggers.app.warning('genClient', e);
if (spi.alterUrl == null) rethrow;
try {
final ipPort = spi.fromStringUrl();
final res = spi.fromStringUrl();
alterUser = res.$2;
return await SSHSocket.connect(
ipPort.$1,
ipPort.$2,
res.$1,
res.$3,
timeout: timeout,
);
} catch (e) {
Loggers.app.warning('genClient alterUrl', e);
rethrow;
}
}
@@ -110,7 +116,7 @@ Future<SSHClient> genClient(
onStatus?.call(GenSSHClientStatus.pwd);
return SSHClient(
socket,
username: spi.user,
username: alterUser ?? spi.user,
onPasswordRequest: () => spi.pwd,
onUserInfoRequest: onKeyboardInteractive,
// printDebug: debugPrint,

View File

@@ -1,224 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:computer/computer.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:icloud_storage/icloud_storage.dart';
import 'package:logging/logging.dart';
import 'package:server_box/data/model/app/backup.dart';
import 'package:server_box/data/model/app/sync.dart';
import 'package:server_box/data/res/misc.dart';
import 'package:server_box/data/model/app/error.dart';
abstract final class ICloud {
static const _containerId = 'iCloud.tech.lolli.serverbox';
static final _logger = Logger('iCloud');
/// Upload file to iCloud
///
/// - [relativePath] is the path relative to [Paths.doc],
/// must not starts with `/`
/// - [localPath] has higher priority than [relativePath], but only apply
/// to the local path instead of iCloud path
///
/// Return [null] if upload success, [ICloudErr] otherwise
static Future<ICloudErr?> upload({
required String relativePath,
String? localPath,
}) async {
final completer = Completer<ICloudErr?>();
try {
await ICloudStorage.upload(
containerId: _containerId,
filePath: localPath ?? '${Paths.doc}/$relativePath',
destinationRelativePath: relativePath,
onProgress: (stream) {
stream.listen(
null,
onDone: () => completer.complete(null),
onError: (e) => completer.complete(
ICloudErr(type: ICloudErrType.generic, message: '$e'),
),
);
},
);
} catch (e, s) {
_logger.warning('Upload $relativePath failed', e, s);
completer.complete(ICloudErr(type: ICloudErrType.generic, message: '$e'));
}
return completer.future;
}
static Future<List<ICloudFile>> getAll() async {
return await ICloudStorage.gather(
containerId: _containerId,
);
}
static Future<void> delete(String relativePath) async {
try {
await ICloudStorage.delete(
containerId: _containerId,
relativePath: relativePath,
);
} catch (e, s) {
_logger.warning('Delete $relativePath failed', e, s);
}
}
/// Download file from iCloud
///
/// - [relativePath] is the path relative to [Paths.doc],
/// must not starts with `/`
/// - [localPath] has higher priority than [relativePath], but only apply
/// to the local path instead of iCloud path
///
/// Return `null` if upload success, [ICloudErr] otherwise
static Future<ICloudErr?> download({
required String relativePath,
String? localPath,
}) async {
final completer = Completer<ICloudErr?>();
try {
await ICloudStorage.download(
containerId: _containerId,
relativePath: relativePath,
destinationFilePath: localPath ?? '${Paths.doc}/$relativePath',
onProgress: (stream) {
stream.listen(
null,
onDone: () => completer.complete(null),
onError: (e) => completer.complete(
ICloudErr(type: ICloudErrType.generic, message: '$e'),
),
);
},
);
} catch (e, s) {
_logger.warning('Download $relativePath failed', e, s);
completer.complete(ICloudErr(type: ICloudErrType.generic, message: '$e'));
}
return completer.future;
}
/// Sync file between iCloud and local
///
/// - [relativePaths] is the path relative to [Paths.doc],
/// must not starts with `/`
/// - [bakPrefix] is the suffix of backup file, default to [null].
/// All files downloaded from cloud will be suffixed with [bakPrefix].
///
/// Return `null` if upload success, [ICloudErr] otherwise
static Future<SyncResult<String, ICloudErr>> syncFiles({
required Iterable<String> relativePaths,
String? bakPrefix,
}) async {
final uploadFiles = <String>[];
final downloadFiles = <String>[];
try {
final errs = <String, ICloudErr>{};
final allFiles = await getAll();
/// remove files not in relativePaths
allFiles.removeWhere((e) => !relativePaths.contains(e.relativePath));
final missions = <Future<void>>[];
/// upload files not in iCloud
final missed = relativePaths.where((e) {
return !allFiles.any((f) => f.relativePath == e);
});
missions.addAll(missed.map((e) async {
final err = await upload(relativePath: e);
if (err != null) {
errs[e] = err;
}
}));
final docPath = Paths.doc;
/// compare files in iCloud and local
missions.addAll(allFiles.map((file) async {
final relativePath = file.relativePath;
/// Check date
final localFile = File('$docPath/$relativePath');
if (!localFile.existsSync()) {
/// Local file not found, download remote file
final err = await download(relativePath: relativePath);
if (err != null) {
errs[relativePath] = err;
}
return;
}
final localDate = await localFile.lastModified();
final remoteDate = file.contentChangeDate;
/// Same date, skip
if (remoteDate.difference(localDate) == Duration.zero) return;
/// Local is newer than remote, so upload local file
if (remoteDate.isBefore(localDate)) {
await delete(relativePath);
final err = await upload(relativePath: relativePath);
if (err != null) {
errs[relativePath] = err;
}
uploadFiles.add(relativePath);
return;
}
/// Remote is newer than local, so download remote
final localPath = '$docPath/${bakPrefix ?? ''}$relativePath';
final err = await download(
relativePath: relativePath,
localPath: localPath,
);
if (err != null) {
errs[relativePath] = err;
}
downloadFiles.add(relativePath);
}));
await Future.wait(missions);
return SyncResult(up: uploadFiles, down: downloadFiles, err: errs);
} catch (e, s) {
_logger.warning('Sync: $relativePaths failed', e, s);
return SyncResult(up: uploadFiles, down: downloadFiles, err: {
'Generic': ICloudErr(type: ICloudErrType.generic, message: '$e')
});
} finally {
_logger.info('Sync, up: $uploadFiles, down: $downloadFiles');
}
}
static Future<void> sync() async {
final result = await download(relativePath: Miscs.bakFileName);
if (result != null) {
await backup();
return;
}
final dlFile = await File(Paths.bak).readAsString();
final dlBak = await Computer.shared.start(Backup.fromJsonString, dlFile);
await dlBak.restore();
await backup();
}
static Future<void> backup() async {
await Backup.backup();
final uploadResult = await upload(relativePath: Miscs.bakFileName);
if (uploadResult != null) {
_logger.warning('Upload backup failed: $uploadResult');
} else {
_logger.info('Upload backup success');
}
}
}

View File

@@ -1,127 +0,0 @@
import 'dart:io';
import 'package:computer/computer.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:logging/logging.dart';
import 'package:server_box/data/model/app/backup.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/res/misc.dart';
import 'package:server_box/data/res/store.dart';
import 'package:webdav_client/webdav_client.dart';
abstract final class Webdav {
/// Some WebDAV provider only support non-root path
static const _prefix = 'srvbox/';
static var _client = WebdavClient(
url: Stores.setting.webdavUrl.fetch(),
user: Stores.setting.webdavUser.fetch(),
pwd: Stores.setting.webdavPwd.fetch(),
);
static final _logger = Logger('Webdav');
static Future<String?> test(String url, String user, String pwd) async {
final client = WebdavClient(url: url, user: user, pwd: pwd);
try {
await client.ping();
return null;
} catch (e, s) {
_logger.warning('Test failed', e, s);
return e.toString();
}
}
static Future<WebdavErr?> upload({
required String relativePath,
String? localPath,
}) async {
try {
await _client.writeFile(
localPath ?? '${Paths.doc}/$relativePath',
_prefix + relativePath,
);
} catch (e, s) {
_logger.warning('Upload $relativePath failed', e, s);
return WebdavErr(type: WebdavErrType.generic, message: '$e');
}
return null;
}
static Future<WebdavErr?> delete(String relativePath) async {
try {
await _client.remove(_prefix + relativePath);
} catch (e, s) {
_logger.warning('Delete $relativePath failed', e, s);
return WebdavErr(type: WebdavErrType.generic, message: '$e');
}
return null;
}
static Future<WebdavErr?> download({
required String relativePath,
String? localPath,
}) async {
try {
await _client.readFile(
_prefix + relativePath,
localPath ?? '${Paths.doc}/$relativePath',
);
} catch (e) {
_logger.warning('Download $relativePath failed');
return WebdavErr(type: WebdavErrType.generic, message: '$e');
}
return null;
}
static Future<List<String>> list() async {
try {
final list = await _client.readDir(_prefix);
final names = <String>[];
for (final item in list) {
if ((item.isDir ?? true) || item.name == null) continue;
names.add(item.name!);
}
return names;
} catch (e, s) {
_logger.warning('List failed', e, s);
return [];
}
}
static void changeClient(String url, String user, String pwd) {
_client = WebdavClient(url: url, user: user, pwd: pwd);
Stores.setting.webdavUrl.put(url);
Stores.setting.webdavUser.put(user);
Stores.setting.webdavPwd.put(pwd);
}
static Future<void> sync() async {
final result = await download(relativePath: Miscs.bakFileName);
if (result != null) {
await backup();
return;
}
try {
final dlFile = await File(Paths.bak).readAsString();
final dlBak = await Computer.shared.start(Backup.fromJsonString, dlFile);
await dlBak.restore();
} catch (e) {
_logger.warning('Restore failed: $e');
}
await backup();
}
/// Create a local backup and upload it to WebDAV
static Future<void> backup() async {
await Backup.backup();
final uploadResult = await upload(relativePath: Miscs.bakFileName);
if (uploadResult != null) {
_logger.warning('Upload failed: $uploadResult');
} else {
_logger.info('Upload success');
}
}
}

View File

@@ -18,7 +18,7 @@ const backupFormatVersion = 1;
final _logger = Logger('Backup');
@JsonSerializable()
class Backup {
class Backup implements Mergeable {
// backup format version
final int version;
final String date;
@@ -28,6 +28,7 @@ class Backup {
final Map<String, dynamic> container;
final Map<String, dynamic> history;
final int? lastModTime;
final Map<String, dynamic>? settings;
const Backup({
required this.version,
@@ -37,6 +38,7 @@ class Backup {
required this.keys,
required this.container,
required this.history,
required this.settings,
this.lastModTime,
});
@@ -52,16 +54,18 @@ class Backup {
keys = Stores.key.fetch(),
container = Stores.container.box.toJson(),
lastModTime = Stores.lastModTime,
history = Stores.history.box.toJson();
history = Stores.history.box.toJson(),
settings = Stores.setting.box.toJson();
static Future<String> backup([String? name]) async {
final result = _diyEncrypt(json.encode(Backup.loadFromStore().toJson()));
final path = '${Paths.doc}/${name ?? Miscs.bakFileName}';
final path = Paths.doc.joinPath(name ?? Miscs.bakFileName);
await File(path).writeAsString(result);
return path;
}
Future<void> restore({bool force = false}) async {
@override
Future<void> merge({bool force = false}) async {
final curTime = Stores.lastModTime ?? 0;
final bakTime = lastModTime ?? 0;
final shouldRestore = force || curTime < bakTime;
@@ -176,6 +180,29 @@ class Backup {
}
}
// Settings
final settings_ = settings;
if (settings_ != null) {
if (force) {
Stores.setting.box.putAll(settings_);
} else {
final nowSettings = Stores.setting.box.keys.toSet();
final bakSettings = settings_.keys.toSet();
final newSettings = bakSettings.difference(nowSettings);
final delSettings = nowSettings.difference(bakSettings);
final updateSettings = nowSettings.intersection(bakSettings);
for (final s in newSettings) {
Stores.setting.box.put(s, settings_[s]);
}
for (final s in delSettings) {
Stores.setting.box.delete(s);
}
for (final s in updateSettings) {
Stores.setting.box.put(s, settings_[s]);
}
}
}
Provider.reload();
RNodes.app.notify();

View File

@@ -20,6 +20,7 @@ Backup _$BackupFromJson(Map<String, dynamic> json) => Backup(
.toList(),
container: json['container'] as Map<String, dynamic>,
history: json['history'] as Map<String, dynamic>,
settings: json['settings'] as Map<String, dynamic>?,
lastModTime: (json['lastModTime'] as num?)?.toInt(),
);
@@ -32,4 +33,5 @@ Map<String, dynamic> _$BackupToJson(Backup instance) => <String, dynamic>{
'container': instance.container,
'history': instance.history,
'lastModTime': instance.lastModTime,
'settings': instance.settings,
};

View File

@@ -54,7 +54,7 @@ enum ShellFunc {
return '''
mkdir -p $scriptDir
cat > $scriptPath
chmod 744 $scriptPath
chmod 755 $scriptPath
''';
}

View File

@@ -1,26 +1,62 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:server_box/view/page/ping.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/view/page/server/tab.dart';
import 'package:server_box/view/page/setting/entry.dart';
import 'package:server_box/view/page/snippet/list.dart';
import 'package:server_box/view/page/ssh/tab.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/view/page/storage/local.dart';
enum AppTab {
server,
ssh,
file,
snippet,
ping,
settings,
;
Widget get page {
switch (this) {
case server:
return const ServerPage();
case snippet:
return const SnippetListPage();
case ssh:
return const SSHTabPage();
case ping:
return const PingPage();
}
return switch (this) {
server => const ServerPage(),
settings => const SettingsPage(),
ssh => const SSHTabPage(),
file => const LocalFilePage(),
snippet => const SnippetListPage(),
};
}
NavigationDestination get navDestination {
return switch (this) {
server => NavigationDestination(
icon: const Icon(BoxIcons.bx_server),
label: l10n.server,
selectedIcon: const Icon(BoxIcons.bxs_server),
),
settings => NavigationDestination(
icon: const Icon(Icons.settings),
label: libL10n.setting,
selectedIcon: const Icon(Icons.settings),
),
ssh => const NavigationDestination(
icon: Icon(Icons.terminal_outlined),
label: 'SSH',
selectedIcon: Icon(Icons.terminal),
),
snippet => NavigationDestination(
icon: const Icon(Icons.code),
label: l10n.snippet,
selectedIcon: const Icon(Icons.code),
),
file => NavigationDestination(
icon: const Icon(Icons.folder_open),
label: libL10n.file,
selectedIcon: const Icon(Icons.folder),
),
};
}
static List<NavigationDestination> get navDestinations {
return AppTab.values.map((e) => e.navDestination).toList();
}
}

View File

@@ -1,5 +0,0 @@
abstract class TagPickable {
bool containsTag(String tag);
String get tagName;
}

View File

@@ -1,7 +1,6 @@
import 'package:dartssh2/dartssh2.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/app/shell_func.dart';
import 'package:server_box/data/model/app/tag_pickable.dart';
import 'package:server_box/data/model/server/battery.dart';
import 'package:server_box/data/model/server/conn.dart';
import 'package:server_box/data/model/server/cpu.dart';
@@ -14,7 +13,7 @@ import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/model/server/temp.dart';
class Server implements TagPickable {
class Server {
Spi spi;
ServerStatus status;
SSHClient? client;
@@ -27,14 +26,6 @@ class Server implements TagPickable {
this.client,
});
@override
bool containsTag(String tag) {
return spi.tags?.contains(tag) ?? false;
}
@override
String get tagName => spi.id;
bool get needGenClient => conn < ServerConn.connecting;
bool get canViewDetails => conn == ServerConn.finished;

View File

@@ -98,7 +98,7 @@ extension Spix on Spi {
custom?.cmds != old.custom?.cmds;
}
(String, int) fromStringUrl() {
(String ip, String usr, int port) fromStringUrl() {
if (alterUrl == null) {
throw SSHErr(type: SSHErrType.connect, message: 'alterUrl is null');
}
@@ -106,18 +106,22 @@ extension Spix on Spi {
if (splited.length != 2) {
throw SSHErr(type: SSHErrType.connect, message: 'alterUrl no @');
}
final splited2 = splited[1].split(':');
if (splited2.length != 2) {
final usr = splited[0];
final idx = splited[1].lastIndexOf(':');
if (idx == -1) {
throw SSHErr(type: SSHErrType.connect, message: 'alterUrl no :');
}
final ip_ = splited2[0];
final port_ = int.tryParse(splited2[1]) ?? 22;
if (port <= 0 || port > 65535) {
final ip_ = splited[1].substring(0, idx);
final port_ = int.tryParse(splited[1].substring(idx + 1));
if (port_ == null || port_ <= 0 || port_ > 65535) {
throw SSHErr(type: SSHErrType.connect, message: 'alterUrl port error');
}
return (ip_, port_);
return (ip_, usr, port_);
}
/// Just for showing the struct of the class.
///
/// **NOT** the default value.
static const example = Spi(
name: 'name',
ip: 'ip',

View File

@@ -6,13 +6,11 @@ import 'package:json_annotation/json_annotation.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:xterm/core.dart';
import 'package:server_box/data/model/app/tag_pickable.dart';
part 'snippet.g.dart';
@JsonSerializable()
@HiveType(typeId: 2)
class Snippet implements TagPickable {
class Snippet {
@HiveField(0)
final String name;
@HiveField(1)
@@ -39,14 +37,6 @@ class Snippet implements TagPickable {
Map<String, dynamic> toJson() => _$SnippetToJson(this);
@override
bool containsTag(String tag) {
return tags?.contains(tag) ?? false;
}
@override
String get tagName => name;
static final fmtFinder = RegExp(r'\$\{[^{}]+\}');
String fmtWithSpi(Spi spi) {
@@ -109,11 +99,21 @@ class Snippet implements TagPickable {
if (special != null) {
final raw = key.substring(special.key.length + 1, key.length - 1);
await special.value((term: terminal, raw: raw));
} else {
// Term keys
final termKey = _find(fmtTermKeys, key);
if (termKey != null) {
await _doTermKeys(terminal, termKey, key);
} else {
// Normal input
terminal.textInput(key);
}
}
// Term keys
final termKey = _find(fmtTermKeys, key);
if (termKey != null) await _doTermKeys(terminal, termKey, key);
// Text between this and next match
if (idx < starts.length - 1) {
terminal.textInput(argsFmted.substring(end, starts[idx + 1]));
}
}
// End term input
@@ -129,10 +129,10 @@ class Snippet implements TagPickable {
MapEntry<String, TerminalKey> termKey,
String key,
) async {
if (termKey.value == TerminalKey.enter) {
terminal.keyInput(TerminalKey.enter);
return;
}
// if (termKey.value == TerminalKey.enter) {
// terminal.keyInput(TerminalKey.enter);
// return;
// }
final ctrlAlt = switch (termKey.value) {
TerminalKey.control => (ctrl: true, alt: false),
@@ -140,6 +140,8 @@ class Snippet implements TagPickable {
_ => (ctrl: false, alt: false),
};
if (!key.contains('+')) return;
// `${ctrl+ad}` -> `ctrla + d`
final chars = key.substring(termKey.key.length + 1, key.length - 1);
if (chars.isEmpty) return;

View File

@@ -1,35 +0,0 @@
import 'package:fl_lib/fl_lib.dart';
class AbsolutePath {
String _path;
final _prePath = <String>[];
AbsolutePath(this._path);
String get path => _path;
/// Update path, not set path
set path(String newPath) {
_prePath.add(_path);
if (newPath == '..') {
_path = _path.substring(0, _path.lastIndexOf('/'));
if (_path == '') {
_path = '/';
}
return;
}
if (newPath.startsWith('/')) {
_path = newPath;
return;
}
_path = _path.joinPath(newPath, seperator: '/');
}
bool undo() {
if (_prePath.isEmpty) {
return false;
}
_path = _prePath.removeLast();
return true;
}
}

View File

@@ -1,12 +1,49 @@
import 'package:dartssh2/dartssh2.dart';
import 'package:server_box/data/model/sftp/absolute_path.dart';
import 'package:fl_lib/fl_lib.dart';
/// Remote server only can be linux-like system, so use '/' as seperator
const _sep = '/';
class SftpBrowserStatus {
final List<SftpName> files = [];
final AbsolutePath path = AbsolutePath('/');
final path = _AbsolutePath(_sep);
SftpClient? client;
SftpBrowserStatus(SSHClient client) {
client.sftp().then((value) => this.client = value);
}
}
class _AbsolutePath {
String _path;
final _prePath = <String>[];
_AbsolutePath(this._path);
String get path => _path;
/// Update path, not set path
set path(String newPath) {
_prePath.add(_path);
if (newPath == '..') {
_path = _path.substring(0, _path.lastIndexOf(_sep));
if (_path == '') {
_path = _sep;
}
return;
}
if (newPath.startsWith(_sep)) {
_path = newPath;
return;
}
_path = _path.joinPath(newPath, seperator: _sep);
}
bool undo() {
if (_prePath.isEmpty) {
return false;
}
_path = _prePath.removeLast();
return true;
}
}

View File

@@ -1,10 +1,14 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/res/store.dart';
import 'package:xterm/core.dart';
part 'virtual_key.g.dart';
enum VirtualKeyFunc { toggleIME, backspace, clipboard, snippet, file }
@HiveType(typeId: 4)
enum VirtKey {
@HiveField(0)
@@ -38,23 +42,80 @@ enum VirtKey {
@HiveField(14)
pgup,
@HiveField(15)
pgdn;
pgdn,
@HiveField(16)
slash,
@HiveField(17)
backSlash,
@HiveField(18)
underscore,
@HiveField(19)
plus,
@HiveField(20)
equal,
@HiveField(21)
minus,
@HiveField(22)
parenLeft,
@HiveField(23)
parenRight,
@HiveField(24)
bracketLeft,
@HiveField(25)
bracketRight,
@HiveField(26)
braceLeft,
@HiveField(27)
braceRight,
@HiveField(28)
chevronLeft,
@HiveField(29)
chevronRight,
@HiveField(30)
colon,
@HiveField(31)
semicolon,
;
}
extension VirtKeyX on VirtKey {
/// Used for input to terminal
String? get inputRaw => switch (this) {
VirtKey.slash => '/',
VirtKey.backSlash => '\\',
VirtKey.underscore => '_',
VirtKey.plus => '+',
VirtKey.equal => '=',
VirtKey.minus => '-',
VirtKey.parenLeft => '(',
VirtKey.parenRight => ')',
VirtKey.bracketLeft => '[',
VirtKey.bracketRight => ']',
VirtKey.braceLeft => '{',
VirtKey.braceRight => '}',
VirtKey.chevronLeft => '<',
VirtKey.chevronRight => '>',
VirtKey.colon => ':',
VirtKey.semicolon => ';',
_ => null,
};
/// Used for displaying on UI
String get text {
switch (this) {
case VirtKey.pgdn:
return 'PgDn';
case VirtKey.pgup:
return 'PgUp';
default:
if (name.length > 1) {
return name.substring(0, 1).toUpperCase() + name.substring(1);
}
return name;
final t = inputRaw;
if (t != null) return t;
if (this == VirtKey.pgdn) return 'PgDn';
if (this == VirtKey.pgup) return 'PgUp';
if (name.length > 1) {
return name.substring(0, 1).toUpperCase() + name.substring(1);
}
return name;
}
static final defaultOrder = [
/// Default order of virtual keys
static const defaultOrder = [
VirtKey.esc,
VirtKey.alt,
VirtKey.home,
@@ -71,99 +132,56 @@ enum VirtKey {
VirtKey.ime,
];
TerminalKey? get key {
switch (this) {
case VirtKey.esc:
return TerminalKey.escape;
case VirtKey.alt:
return TerminalKey.alt;
case VirtKey.home:
return TerminalKey.home;
case VirtKey.up:
return TerminalKey.arrowUp;
case VirtKey.end:
return TerminalKey.end;
case VirtKey.tab:
return TerminalKey.tab;
case VirtKey.ctrl:
return TerminalKey.control;
case VirtKey.left:
return TerminalKey.arrowLeft;
case VirtKey.down:
return TerminalKey.arrowDown;
case VirtKey.right:
return TerminalKey.arrowRight;
case VirtKey.pgup:
return TerminalKey.pageUp;
case VirtKey.pgdn:
return TerminalKey.pageDown;
default:
return null;
}
}
/// Corresponding [TerminalKey]
TerminalKey? get key => switch (this) {
VirtKey.esc => TerminalKey.escape,
VirtKey.alt => TerminalKey.alt,
VirtKey.home => TerminalKey.home,
VirtKey.up => TerminalKey.arrowUp,
VirtKey.end => TerminalKey.end,
VirtKey.tab => TerminalKey.tab,
VirtKey.ctrl => TerminalKey.control,
VirtKey.left => TerminalKey.arrowLeft,
VirtKey.down => TerminalKey.arrowDown,
VirtKey.right => TerminalKey.arrowRight,
VirtKey.pgup => TerminalKey.pageUp,
VirtKey.pgdn => TerminalKey.pageDown,
_ => null,
};
IconData? get icon {
switch (this) {
case VirtKey.up:
return Icons.arrow_upward;
case VirtKey.left:
return Icons.arrow_back;
case VirtKey.down:
return Icons.arrow_downward;
case VirtKey.right:
return Icons.arrow_forward;
case VirtKey.sftp:
return Icons.file_open;
case VirtKey.snippet:
return Icons.code;
case VirtKey.clipboard:
return Icons.paste;
case VirtKey.ime:
return Icons.keyboard;
default:
return null;
}
}
/// Icons for virtual keys
IconData? get icon => switch (this) {
VirtKey.up => Icons.arrow_upward,
VirtKey.left => Icons.arrow_back,
VirtKey.down => Icons.arrow_downward,
VirtKey.right => Icons.arrow_forward,
VirtKey.sftp => Icons.file_open,
VirtKey.snippet => Icons.code,
VirtKey.clipboard => Icons.paste,
VirtKey.ime => Icons.keyboard,
_ => null,
};
// Use [VirtualKeyFunc] instead of [VirtKey]
// This can help linter to enum all [VirtualKeyFunc]
// and make sure all [VirtualKeyFunc] are handled
VirtualKeyFunc? get func {
switch (this) {
case VirtKey.sftp:
return VirtualKeyFunc.file;
case VirtKey.snippet:
return VirtualKeyFunc.snippet;
case VirtKey.clipboard:
return VirtualKeyFunc.clipboard;
case VirtKey.ime:
return VirtualKeyFunc.toggleIME;
default:
return null;
}
}
VirtualKeyFunc? get func => switch (this) {
VirtKey.sftp => VirtualKeyFunc.file,
VirtKey.snippet => VirtualKeyFunc.snippet,
VirtKey.clipboard => VirtualKeyFunc.clipboard,
VirtKey.ime => VirtualKeyFunc.toggleIME,
_ => null,
};
bool get toggleable {
switch (this) {
case VirtKey.alt:
case VirtKey.ctrl:
return true;
default:
return false;
}
}
bool get toggleable => switch (this) {
VirtKey.alt || VirtKey.ctrl => true,
_ => false,
};
bool get canLongPress {
switch (this) {
case VirtKey.up:
case VirtKey.left:
case VirtKey.down:
case VirtKey.right:
return true;
default:
return false;
}
}
bool get canLongPress => switch (this) {
VirtKey.up || VirtKey.left || VirtKey.down || VirtKey.right => true,
_ => false,
};
String? get help => switch (this) {
VirtKey.sftp => l10n.virtKeyHelpSFTP,
@@ -171,6 +189,18 @@ enum VirtKey {
VirtKey.ime => l10n.virtKeyHelpIME,
_ => null,
};
}
enum VirtualKeyFunc { toggleIME, backspace, clipboard, snippet, file }
/// - [saveDefaultIfErr] if the stored raw values is invalid, save default order to store
static List<VirtKey> loadFromStore({bool saveDefaultIfErr = true}) {
try {
final ints = Stores.setting.sshVirtKeys.fetch();
return ints.map((e) => VirtKey.values[e]).toList();
} on RangeError {
final ints = defaultOrder.map((e) => e.index).toList();
Stores.setting.sshVirtKeys.put(ints);
} catch (e, s) {
Loggers.app.warning('Failed to load sshVirtKeys', e, s);
}
return defaultOrder;
}
}

View File

@@ -45,6 +45,38 @@ class VirtKeyAdapter extends TypeAdapter<VirtKey> {
return VirtKey.pgup;
case 15:
return VirtKey.pgdn;
case 16:
return VirtKey.slash;
case 17:
return VirtKey.backSlash;
case 18:
return VirtKey.underscore;
case 19:
return VirtKey.plus;
case 20:
return VirtKey.equal;
case 21:
return VirtKey.minus;
case 22:
return VirtKey.parenLeft;
case 23:
return VirtKey.parenRight;
case 24:
return VirtKey.bracketLeft;
case 25:
return VirtKey.bracketRight;
case 26:
return VirtKey.braceLeft;
case 27:
return VirtKey.braceRight;
case 28:
return VirtKey.chevronLeft;
case 29:
return VirtKey.chevronRight;
case 30:
return VirtKey.colon;
case 31:
return VirtKey.semicolon;
default:
return VirtKey.esc;
}
@@ -101,6 +133,54 @@ class VirtKeyAdapter extends TypeAdapter<VirtKey> {
case VirtKey.pgdn:
writer.writeByte(15);
break;
case VirtKey.slash:
writer.writeByte(16);
break;
case VirtKey.backSlash:
writer.writeByte(17);
break;
case VirtKey.underscore:
writer.writeByte(18);
break;
case VirtKey.plus:
writer.writeByte(19);
break;
case VirtKey.equal:
writer.writeByte(20);
break;
case VirtKey.minus:
writer.writeByte(21);
break;
case VirtKey.parenLeft:
writer.writeByte(22);
break;
case VirtKey.parenRight:
writer.writeByte(23);
break;
case VirtKey.bracketLeft:
writer.writeByte(24);
break;
case VirtKey.bracketRight:
writer.writeByte(25);
break;
case VirtKey.braceLeft:
writer.writeByte(26);
break;
case VirtKey.braceRight:
writer.writeByte(27);
break;
case VirtKey.chevronLeft:
writer.writeByte(28);
break;
case VirtKey.chevronRight:
writer.writeByte(29);
break;
case VirtKey.colon:
writer.writeByte(30);
break;
case VirtKey.semicolon:
writer.writeByte(31);
break;
}
}

View File

@@ -1,4 +1,5 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/core/sync.dart';
import 'package:server_box/data/model/server/private_key_info.dart';
import 'package:server_box/data/res/store.dart';
@@ -18,12 +19,14 @@ class PrivateKeyProvider extends Provider {
pkis.value.add(info);
pkis.notify();
Stores.key.put(info);
bakSync.sync(milliDelay: 1000);
}
static void delete(PrivateKeyInfo info) {
pkis.value.removeWhere((e) => e.id == info.id);
pkis.notify();
Stores.key.delete(info);
bakSync.sync(milliDelay: 1000);
}
static void update(PrivateKeyInfo old, PrivateKeyInfo newInfo) {
@@ -37,5 +40,6 @@ class PrivateKeyProvider extends Provider {
Stores.key.put(newInfo);
}
pkis.notify();
bakSync.sync(milliDelay: 1000);
}
}

View File

@@ -5,6 +5,7 @@ import 'package:computer/computer.dart';
import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/core/extension/ssh_client.dart';
import 'package:server_box/core/sync.dart';
import 'package:server_box/core/utils/ssh_auth.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/app/shell_func.dart';
@@ -87,16 +88,15 @@ class ServerProvider extends Provider {
}
static void _updateTags() {
final tags = <String>{};
for (final s in servers.values) {
final tags = s.value.spi.tags;
if (tags == null) continue;
for (final t in tags) {
if (!_tags.value.contains(t)) {
_tags.value.add(t);
}
final spiTags = s.value.spi.tags;
if (spiTags == null) continue;
for (final t in spiTags) {
tags.add(t);
}
}
_tags.value = (_tags.value.toList()..sort()).toSet();
_tags.value = tags;
}
static Server genServer(Spi spi) {
@@ -190,6 +190,7 @@ class ServerProvider extends Provider {
Stores.setting.serverOrder.put(serverOrder.value);
_updateTags();
refresh(spi: spi);
bakSync.sync(milliDelay: 1000);
}
static void delServer(String id) {
@@ -199,6 +200,7 @@ class ServerProvider extends Provider {
Stores.setting.serverOrder.put(serverOrder.value);
Stores.server.delete(id);
_updateTags();
bakSync.sync(milliDelay: 1000);
}
static void deleteAll() {
@@ -234,6 +236,7 @@ class ServerProvider extends Provider {
}
}
_updateTags();
bakSync.sync();
}
static void _setServerState(VNode<Server> s, ServerConn ss) {
@@ -302,11 +305,10 @@ class ServerProvider extends Provider {
_setServerState(s, ServerConn.connected);
final scriptRaw = ShellFunc.allScript(spi.custom?.cmds).uint8List;
try {
final (_, writeScriptResult) = await sv.client!.exec(
(session) async {
final scriptRaw = ShellFunc.allScript(spi.custom?.cmds).uint8List;
session.stdin.add(scriptRaw);
session.stdin.close();
},

View File

@@ -1,4 +1,5 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/core/sync.dart';
import 'package:server_box/data/model/server/snippet.dart';
import 'package:server_box/data/res/store.dart';
@@ -44,6 +45,7 @@ class SnippetProvider extends Provider {
snippets.notify();
Stores.snippet.put(snippet);
_updateTags();
bakSync.sync(milliDelay: 1000);
}
static void del(Snippet snippet) {
@@ -51,6 +53,7 @@ class SnippetProvider extends Provider {
snippets.notify();
Stores.snippet.delete(snippet);
_updateTags();
bakSync.sync(milliDelay: 1000);
}
static void update(Snippet old, Snippet newOne) {
@@ -60,6 +63,7 @@ class SnippetProvider extends Provider {
Stores.snippet.delete(old);
Stores.snippet.put(newOne);
_updateTags();
bakSync.sync(milliDelay: 1000);
}
static void renameTag(String old, String newOne) {
@@ -71,5 +75,6 @@ class SnippetProvider extends Provider {
}
}
_updateTags();
bakSync.sync(milliDelay: 1000);
}
}

View File

@@ -19,6 +19,11 @@ final class SystemdProvider {
final isBusy = false.vn;
final units = <SystemdUnit>[].vn;
void dispose() {
isBusy.dispose();
units.dispose();
}
Future<void> getUnits() async {
isBusy.value = true;

View File

@@ -1,8 +1,8 @@
// This file is generated by fl_build. Do not edit.
// ignore_for_file: prefer_single_quotes
class BuildData {
abstract class BuildData {
static const String name = "ServerBox";
static const int build = 1070;
static const int build = 1104;
static const int script = 58;
}

View File

@@ -16,6 +16,7 @@ abstract final class GithubIds {
'Liloupar',
'dccif',
'mikropsoft',
'CakesTwix',
};
static const participants = <GhId>{

View File

@@ -1,35 +1,38 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/store/container.dart';
import 'package:server_box/data/store/history.dart';
import 'package:server_box/data/store/no_backup.dart';
import 'package:server_box/data/store/private_key.dart';
import 'package:server_box/data/store/server.dart';
import 'package:server_box/data/store/setting.dart';
import 'package:server_box/data/store/snippet.dart';
abstract final class Stores {
static final setting = SettingStore();
static final server = ServerStore();
static final container = ContainerStore();
static final history = HistoryStore();
static final key = PrivateKeyStore();
static final snippet = SnippetStore();
static final setting = SettingStore.instance;
static final server = ServerStore.instance;
static final container = ContainerStore.instance;
static final key = PrivateKeyStore.instance;
static final snippet = SnippetStore.instance;
static final history = HistoryStore.instance;
static final List<PersistentStore> all = [
setting,
server,
container,
history,
key,
snippet,
/// All stores that need backup
static final List<PersistentStore> _allBackup = [
SettingStore.instance,
ServerStore.instance,
ContainerStore.instance,
PrivateKeyStore.instance,
SnippetStore.instance,
HistoryStore.instance,
];
static Future<void> init() async {
await Future.wait(all.map((store) => store.init()));
await Future.wait(_allBackup.map((store) => store.init()));
await NoBackupStore.instance.init();
}
static int? get lastModTime {
int? lastModTime = 0;
for (final store in all) {
for (final store in _allBackup) {
final last = store.box.lastModified ?? 0;
if (last > (lastModTime ?? 0)) {
lastModTime = last;

View File

@@ -5,7 +5,9 @@ import 'package:server_box/data/res/store.dart';
const _keyConfig = 'providerConfig';
class ContainerStore extends PersistentStore {
ContainerStore() : super('docker');
ContainerStore._() : super('docker');
static final instance = ContainerStore._();
String? fetch(String? id) {
return box.get(id);

View File

@@ -46,7 +46,9 @@ class _MapHistory {
}
class HistoryStore extends PersistentStore {
HistoryStore() : super('history');
HistoryStore._() : super('history');
static final instance = HistoryStore._();
/// Paths that user has visited by 'Locate' button
late final sftpGoPath = _ListHistory(box: box, name: 'sftpPath');

View File

@@ -0,0 +1,49 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/res/build_data.dart';
import 'package:server_box/data/res/store.dart';
final class NoBackupStore extends PersistentStore {
NoBackupStore._() : super('no_backup');
static final instance = NoBackupStore._();
/// Only valid on iOS and macOS
late final icloudSync = property('icloudSync', false);
/// Webdav sync
late final webdavSync = property('webdavSync', false);
late final webdavUrl = property('webdavUrl', '');
late final webdavUser = property('webdavUser', '');
late final webdavPwd = property('webdavPwd', '');
void migrate() {
if (BuildData.build > 1076) return;
final settings = Stores.setting;
final icloudSync_ = settings.box.get('icloudSync');
if (icloudSync_ is bool) {
icloudSync.put(icloudSync_);
settings.box.delete('icloudSync');
}
final webdavSync_ = settings.box.get('webdavSync');
if (webdavSync_ is bool) {
webdavSync.put(webdavSync_);
settings.box.delete('webdavSync');
}
final webdavUrl_ = settings.box.get('webdavUrl');
if (webdavUrl_ is String) {
webdavUrl.put(webdavUrl_);
settings.box.delete('webdavUrl');
}
final webdavUser_ = settings.box.get('webdavUser');
if (webdavUser_ is String) {
webdavUser.put(webdavUser_);
settings.box.delete('webdavUser');
}
final webdavPwd_ = settings.box.get('webdavPwd');
if (webdavPwd_ is String) {
webdavPwd.put(webdavPwd_);
settings.box.delete('webdavPwd');
}
}
}

View File

@@ -3,7 +3,9 @@ import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/model/server/private_key_info.dart';
class PrivateKeyStore extends PersistentStore {
PrivateKeyStore() : super('key');
PrivateKeyStore._() : super('key');
static final instance = PrivateKeyStore._();
void put(PrivateKeyInfo info) {
box.put(info.id, info);

View File

@@ -3,7 +3,9 @@ import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
class ServerStore extends PersistentStore {
ServerStore() : super('server');
ServerStore._() : super('server');
static final instance = ServerStore._();
void put(Spi info) {
box.put(info.id, info);

View File

@@ -7,7 +7,9 @@ import 'package:server_box/data/model/app/net_view.dart';
import 'package:server_box/data/res/default.dart';
class SettingStore extends PersistentStore {
SettingStore() : super('setting');
SettingStore._() : super('setting');
static final instance = SettingStore._();
// ------BEGIN------
//
@@ -104,7 +106,7 @@ class SettingStore extends PersistentStore {
late final sshVirtKeys = listProperty(
'sshVirtKeys',
VirtKey.defaultOrder.map((e) => e.index).toList(),
VirtKeyX.defaultOrder.map((e) => e.index).toList(),
);
late final netViewType = property('netViewType', NetViewType.speed);
@@ -125,9 +127,6 @@ class SettingStore extends PersistentStore {
/// Whether use system's primary color as the app's primary color
late final useSystemPrimaryColor = property('useSystemPrimaryColor', false);
/// Only valid on iOS and macOS
late final icloudSync = property('icloudSync', false);
/// Only valid on iOS / Android / Windows
late final useBioAuth = property('useBioAuth', false);
@@ -143,12 +142,6 @@ class SettingStore extends PersistentStore {
/// Show tip of suspend
late final showSuspendTip = property('showSuspendTip', true);
/// Webdav sync
late final webdavSync = property('webdavSync', false);
late final webdavUrl = property('webdavUrl', '', updateLastModified: false);
late final webdavUser = property('webdavUser', '', updateLastModified: false);
late final webdavPwd = property('webdavPwd', '', updateLastModified: false);
/// Whether collapse UI items by default
late final collapseUIDefault = property('collapseUIDefault', true);

View File

@@ -3,7 +3,9 @@ import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/model/server/snippet.dart';
class SnippetStore extends PersistentStore {
SnippetStore() : super('snippet');
SnippetStore._() : super('snippet');
static final instance = SnippetStore._();
void put(Snippet snippet) {
box.put(snippet.name, snippet);

View File

@@ -70,14 +70,9 @@ final class _IntroPage extends StatelessWidget {
).cardx,
ListTile(
leading: const Icon(MingCute.chart_line_line, size: _kIconSize),
title: TipText(l10n.stat, l10n.parseContainerStatsTip),
title: TipText('Docker ${l10n.stat}', l10n.parseContainerStatsTip),
trailing: StoreSwitch(prop: _setting.containerParseStat),
).cardx,
ListTile(
leading: const Icon(OctIcons.cpu),
title: TipText(l10n.noLineChartForCpu, l10n.cpuViewAsProgressTip),
trailing: StoreSwitch(prop: _setting.cpuViewAsProgress),
).cardx,
ListTile(
leading: const Icon(Bootstrap.alphabet),
title: TipText(l10n.letterCache, l10n.letterCacheTip),

View File

@@ -142,6 +142,7 @@
"route": "Routen",
"run": "Ausführen",
"running": "läuft",
"sameIdServerExist": "Ein Server mit derselben ID existiert bereits",
"save": "Speichern",
"saved": "Gerettet",
"second": "s",

View File

@@ -142,6 +142,7 @@
"route": "Routing",
"run": "Run",
"running": "Running",
"sameIdServerExist": "A server with the same ID already exists",
"save": "Save",
"saved": "Saved",
"second": "s",

View File

@@ -142,6 +142,7 @@
"route": "Enrutamiento",
"run": "Ejecutar",
"running": "En ejecución",
"sameIdServerExist": "Ya existe un servidor con el mismo ID",
"save": "Guardar",
"saved": "Guardado",
"second": "Segundo",

View File

@@ -142,6 +142,7 @@
"route": "Routage",
"run": "Exécuter",
"running": "En cours d'exécution",
"sameIdServerExist": "Un serveur avec le même ID existe déjà",
"save": "Enregistrer",
"saved": "Enregistré",
"second": "s",

View File

@@ -142,6 +142,7 @@
"route": "Routing",
"run": "Berlari",
"running": "berlari",
"sameIdServerExist": "Server dengan ID yang sama sudah ada",
"save": "Menyimpan",
"saved": "Diselamatkan",
"second": "S",

View File

@@ -87,7 +87,7 @@
"license": "オープンソースライセンス",
"location": "場所",
"loss": "パケットロス",
"madeWithLove": "❤️で作成された {myGithub}",
"madeWithLove": "{myGithub}によって❤️で作成済み",
"manual": "マニュアル",
"max": "最大",
"maxRetryCount": "サーバーの再接続試行回数",
@@ -142,6 +142,7 @@
"route": "ルーティング",
"run": "実行",
"running": "実行中",
"sameIdServerExist": "同じIDのサーバーが既に存在します",
"save": "保存",
"saved": "保存されました",
"second": "秒",
@@ -164,7 +165,7 @@
"specifyDev": "デバイスを指定",
"specifyDevTip": "例えば、ネットワークトラフィック統計はデフォルトですべてのデバイスに対するものです。ここで特定のデバイスを指定できます。",
"speed": "速度",
"spentTime": "時間を費やしました: {time}",
"spentTime": "費した時間: {time}",
"sshTermHelp": "ターミナルがスクロール可能な場合、横にドラッグするとテキストを選択できます。キーボードボタンをクリックするとキーボードのオン/オフが切り替わります。ファイルアイコンは現在のパスSFTPを開きます。クリップボードボタンは、テキストが選択されているときに内容をコピーし、テキストが選択されておらずクリップボードに内容がある場合には、その内容をターミナルに貼り付けます。コードアイコンは、コードスニペットをターミナルに貼り付けて実行します。",
"sshTip": "この機能は現在テスト段階にあります。\n\n問題がある場合は、{url}でフィードバックしてください。",
"sshVirtualKeyAutoOff": "仮想キーの自動オフ",

View File

@@ -142,6 +142,7 @@
"route": "Route",
"run": "Uitvoeren",
"running": "Uitgevoerd",
"sameIdServerExist": "Er bestaat al een server met dezelfde ID",
"save": "Opslaan",
"saved": "Opgeslagen",
"second": "s",

View File

@@ -142,6 +142,7 @@
"route": "Roteamento",
"run": "Executar",
"running": "Executando",
"sameIdServerExist": "Já existe um servidor com o mesmo ID",
"save": "Salvar",
"saved": "Salvo",
"second": "Segundo",

View File

@@ -3,77 +3,77 @@
"aboutThanks": "Благодарности всем участникам.",
"acceptBeta": "Принять обновления тестовой версии",
"addSystemPrivateKeyTip": "В данный момент приватные ключи отсутствуют. Добавить системный приватный ключ (~/.ssh/id_rsa)?",
"added2List": "добавлено в список задач",
"added2List": "Добавлено в список задач",
"addr": "Адрес",
"alreadyLastDir": "Уже в корневом каталоге",
"authFailTip": "Аутентификация не удалась, пожалуйста, проверьте, правильны ли пароль/ключ/хост/пользователь и т.д.",
"autoBackupConflict": "Может быть включено только одно автоматическое резервное копирование",
"autoConnect": "автоматическое подключение",
"autoRun": "автозапуск",
"autoUpdateHomeWidget": "автоматическое обновление виджета на главном экране",
"backupTip": "Экспортированные данные зашифрованы простым способом, пожалуйста, храните их в безопасности.",
"autoConnect": "Автоматическое подключение",
"autoRun": "Автозапуск",
"autoUpdateHomeWidget": "Автоматическое обновление виджета на главном экране",
"backupTip": "Экспортированные данные зашифрованы простым способом \nПожалуйста, храните их в безопасности.",
"backupVersionNotMatch": "Версия резервной копии не совпадает, восстановление невозможно",
"battery": "батарея",
"bgRun": "работа в фоновом режиме",
"bgRunTip": "Этот переключатель означает, что программа будет пытаться работать в фоновом режиме, но фактическое выполнение зависит от того, включено ли разрешение. Для нативного Android отключите «Оптимизацию батареи» для этого приложения, для MIUI измените стратегию энергосбережения на «Без ограничений».",
"cmd": "команда",
"battery": "Батарея",
"bgRun": "Работа в фоновом режиме",
"bgRunTip": "Этот переключатель означает, что программа будет пытаться работать в фоновом режиме, но фактическое выполнение зависит от того, включено ли разрешение. Для нативного Android отключите «Оптимизацию батареи» для этого приложения, для MIUI измените контроль активности на «Нет ограничений».",
"cmd": "Команда",
"collapseUITip": "Свернуть длинные списки в UI по умолчанию",
"conn": "подключение",
"container": "контейнер",
"conn": "Подключение",
"container": "Контейнер",
"containerTrySudoTip": "Например: если пользователь в приложении установлен как aaa, но Docker установлен под пользователем root, тогда нужно включить эту опцию",
"convert": "конвертировать",
"copyPath": "копировать путь",
"convert": "Конвертировать",
"copyPath": "Копировать путь",
"cpuViewAsProgressTip": "Отобразите уровень использования каждого процессора в виде индикатора выполнения (старый стиль)",
"cursorType": "Тип курсора",
"customCmd": "Пользовательские команды",
"customCmdDocUrl": "https://github.com/lollipopkit/flutter_server_box/wiki#custom-commands",
"customCmdHint": "\"Имя команды\": \"Команда\"",
"decode": "декодировать",
"decompress": "разархивировать",
"deleteServers": "удалить серверы пакетно",
"decode": "Декодировать",
"decompress": "Разархивировать",
"deleteServers": "Удалить серверы пакетно",
"dirEmpty": "Пожалуйста, убедитесь, что папка пуста",
"disconnected": "отключено",
"disk": "диск",
"diskIgnorePath": "путь игнорирования диска",
"displayCpuIndex": "Показать индекс ЦПУ",
"disconnected": "Отключено",
"disk": "Диск",
"diskIgnorePath": "Игнорировать путь к диску",
"displayCpuIndex": "Отобразить индекс ЦП",
"dl2Local": "Загрузить {fileName} на локальный диск?",
"dockerEmptyRunningItems": "Нет запущенных контейнеров.\nЭто может быть из-за:\n- пользователя Docker, отличного от пользователя, настроенного в приложении\n- переменной окружения DOCKER_HOST, которая не была правильно считана. Вы можете выполнить `echo $DOCKER_HOST` в терминале, чтобы увидеть ее значение.",
"dockerImagesFmt": "Всего {count} образов",
"dockerNotInstalled": "Docker не установлен",
"dockerStatusRunningAndStoppedFmt": "{runningCount} запущено, {stoppedCount} остановлено",
"dockerStatusRunningFmt": "{count} контейнеров запущено",
"doubleColumnMode": "режим двойной колонки",
"doubleColumnMode": "Режим двойной колонки",
"doubleColumnTip": "Эта опция лишь включает функцию; фактическое применение зависит от ширины устройства",
"editVirtKeys": "редактировать виртуальные клавиши",
"editor": "редактор",
"editVirtKeys": "Редактировать виртуальные клавиши",
"editor": "Редактор",
"editorHighlightTip": "Текущая производительность подсветки кода неудовлетворительна, можно отключить для улучшения.",
"encode": "кодировать",
"encode": "Кодировать",
"envVars": "Переменная окружения",
"experimentalFeature": "экспериментальная функция",
"extraArgs": "дополнительные аргументы",
"experimentalFeature": "Экспериментальная функция",
"extraArgs": "Дополнительные аргументы",
"fallbackSshDest": "Резервное место назначения SSH",
"fdroidReleaseTip": "Если вы скачали это приложение с F-Droid, рекомендуется отключить эту опцию.",
"fileTooLarge": "Файл '{file}' слишком большой '{size}', превышает {sizeMax}",
"followSystem": "следовать за системой",
"font": "шрифт",
"fontSize": "размер шрифта",
"force": "принудительно",
"fullScreen": "полноэкранный режим",
"fullScreenJitter": "дрожание в полноэкранном режиме",
"fullScreenJitterHelp": "предотвращение выгорания экрана",
"followSystem": "Следовать за системой",
"font": "Шрифт",
"fontSize": "Размер шрифта",
"force": "Принудительно",
"fullScreen": "Полноэкранный режим",
"fullScreenJitter": "Вибрация в полноэкранном режиме",
"fullScreenJitterHelp": "Предотвращение выгорания экрана",
"fullScreenTip": "Следует ли включить полноэкранный режим, когда устройство поворачивается в альбомный режим? Эта опция применяется только к вкладке сервера.",
"goBackQ": "Вернуться?",
"goto": "перейти к",
"goto": "Перейти к",
"hideTitleBar": "Скрыть заголовок",
"highlight": "подсветка кода",
"homeWidgetUrlConfig": "конфигурация URL виджета домашнего экрана",
"host": "хост",
"httpFailedWithCode": "Ошибка запроса, код: {code}",
"highlight": "Подсветка кода",
"homeWidgetUrlConfig": "Конфигурация URL виджета домашнего экрана",
"host": "Хост",
"httpFailedWithCode": "ошибка запроса, код: {code}",
"ignoreCert": "Игнорировать сертификат",
"image": "образ",
"imagesList": "список образов",
"image": "Образ",
"imagesList": "Список образов",
"init": "Инициализировать",
"inner": "встроенный",
"inner": "Встроенный",
"install": "установить",
"installDockerWithUrl": "Пожалуйста, сначала установите Docker по адресу https://docs.docker.com/engine/install",
"invalid": "Недействительный",
@@ -81,135 +81,136 @@
"keepForeground": "Пожалуйста, держите приложение в фокусе!",
"keepStatusWhenErr": "Сохранять статус сервера при ошибке",
"keepStatusWhenErrTip": "Применимо только в случае ошибки выполнения скрипта",
"keyAuth": "аутентификация по ключу",
"keyAuth": "Аутентификация по ключу",
"letterCache": "Кэширование букв",
"letterCacheTip": "Рекомендуется отключить, но после отключения будет невозможно вводить символы CJK.",
"license": "лицензия",
"location": "местоположение",
"loss": "потери пакетов",
"license": "Лицензия",
"location": "Местоположение",
"loss": "Потери пакетов",
"madeWithLove": "Создано с ❤️ by {myGithub}",
"manual": "вручную",
"manual": "Вручную",
"max": "максимум",
"maxRetryCount": "максимальное количество попыток переподключения к серверу",
"maxRetryCountEqual0": "будет бесконечно пытаться переподключиться",
"maxRetryCount": "Максимальное количество попыток переподключения к серверу",
"maxRetryCountEqual0": "Будет бесконечно пытаться переподключиться",
"min": "минимум",
"mission": "задача",
"more": "больше",
"mission": "Задача",
"more": "Больше",
"moveOutServerFuncBtnsHelp": "Включено: кнопки функций сервера отображаются под каждой карточкой на вкладке сервера. Выключено: отображается в верхней части страницы деталей сервера.",
"ms": "мс",
"needHomeDir": "Если вы пользователь Synology, [смотрите здесь](https://kb.synology.com/DSM/tutorial/user_enable_home_service). Пользователям других систем нужно искать, как создать домашний каталог.",
"needRestart": "требуется перезапуск приложения",
"net": "сеть",
"netViewType": "тип визуализации сети",
"newContainer": "создать контейнер",
"needRestart": "Требуется перезапуск приложения",
"net": "Сеть",
"netViewType": "Тип визуализации сети",
"newContainer": "Создать контейнер",
"noLineChart": "Не использовать линейные графики",
"noLineChartForCpu": "Не используйте линейные графики для ЦП",
"noPrivateKeyTip": "Приватный ключ не существует, возможно, он был удален или есть ошибка в настройках.",
"noPromptAgain": "Больше не спрашивать",
"node": "Узел",
"notAvailable": "Недоступно",
"onServerDetailPage": "на странице деталей сервера",
"onlyOneLine": "Отображать только в одной строке (прокручиваемо)",
"onlyWhenCoreBiggerThan8": "Действует только при количестве ядер > 8",
"openLastPath": "открыть последний путь",
"onServerDetailPage": "На странице деталей сервера",
"onlyOneLine": "Отображать только в одной строке (прокручивается)",
"onlyWhenCoreBiggerThan8": "Действует только при количестве ядер больше 8",
"openLastPath": "Открыть последний путь",
"openLastPathTip": "Для разных серверов будут сохранены разные записи, записывается путь при выходе",
"parseContainerStatsTip": "Анализ статуса использования Docker может быть медленным",
"percentOfSize": "{percent}% от {size}",
"permission": "Разрешения",
"pingAvg": "Среднее:",
"pingInputIP": "Пожалуйста, введите целевой IP или доменное имя",
"pingNoServer": "Нет доступных серверов для Ping\nПожалуйста, добавьте серверы на вкладке серверов и попробуйте снова",
"pkg": "менеджер пакетов",
"pingAvg": "В среднем:",
"pingInputIP": "Пожалуйста, введите целевой IP или домен",
"pingNoServer": "Нет доступных серверов для Ping\nПожалуйста, добавьте их на вкладке «Сервер» и попробуйте снова",
"pkg": "Менеджер пакетов",
"plugInType": "Тип вставки",
"port": "порт",
"preview": "предпросмотр",
"privateKey": "приватный ключ",
"process": "процесс",
"pushToken": "токен уведомлений",
"port": "Порт",
"preview": "Предпросмотр",
"privateKey": "Приватный ключ",
"process": "Процесс",
"pushToken": "Токен уведомлений",
"pveIgnoreCertTip": "Не рекомендуется включать, обратите внимание на риски безопасности! Если вы используете стандартный сертификат от PVE, вам нужно включить эту опцию.",
"pveLoginFailed": "Ошибка входа. Невозможно аутентифицироваться с помощью имени пользователя/пароля из конфигурации сервера для входа в Linux PAM.",
"pveVersionLow": "Эта функция в настоящее время находится на стадии тестирования и была протестирована только на PVE 8+. Используйте ее с осторожностью.",
"pwd": "пароль",
"read": "чтение",
"reboot": "перезагрузка",
"pwd": "Пароль",
"read": "Чтение",
"reboot": "Перезагрузка",
"rememberPwdInMem": "Запомнить пароль в памяти",
"rememberPwdInMemTip": "Используется для контейнеров, приостановки и т. д.",
"rememberWindowSize": "Запомнить размер окна",
"remotePath": "удаленный путь",
"restart": "перезапустить",
"result": "результат",
"rotateAngel": "угол поворота",
"remotePath": "Удаленный путь",
"restart": "Перезапустить",
"result": "Результат",
"rotateAngel": "Угол поворота",
"route": "Маршрутизация",
"run": "запустить",
"running": "работает",
"save": "сохранить",
"saved": "сохранено",
"second": "секунда",
"sensors": "датчики",
"sequence": "последовательность",
"server": "сервер",
"serverDetailOrder": "порядок элементов на странице деталей сервера",
"serverFuncBtns": "кнопки функций сервера",
"serverOrder": "порядок серверов",
"sftpDlPrepare": "Подготовка к подключению к серверу...",
"run": "Запустить",
"running": "Запущено",
"sameIdServerExist": "Сервер с таким ID уже существует",
"save": "Сохранить",
"saved": "Сохранено",
"second": "с",
"sensors": "Датчики",
"sequence": "Последовательность",
"server": "Сервер",
"serverDetailOrder": "Порядок элементов на странице деталей сервера",
"serverFuncBtns": "Кнопки функций сервера",
"serverOrder": "Порядок серверов",
"sftpDlPrepare": "Подготовка подключения...",
"sftpEditorTip": "Если пусто, используйте встроенный редактор файлов приложения. Если значение указано, используйте редактор удаленного сервера, например, `vim` (рекомендуется автоматически определять согласно `EDITOR`).",
"sftpRmrDirSummary": "Использовать `rm -r` в SFTP для удаления папок",
"sftpSSHConnected": "SFTP подключен...",
"sftpShowFoldersFirst": "показывать папки в начале",
"showDistLogo": "показать лого дистрибутива",
"shutdown": "выключение",
"size": "размер",
"snippet": "фрагмент",
"sftpShowFoldersFirst": "Показывать папки в начале",
"showDistLogo": "Показать лого дистрибутива",
"shutdown": "Выключение",
"size": "Размер",
"snippet": "Фрагмент",
"softWrap": "Мягкий перенос",
"specifyDev": "Указать устройство",
"specifyDevTip": "Например, статистика сетевого трафика по умолчанию относится ко всем устройствам. Здесь вы можете указать конкретное устройство.",
"speed": "скорость",
"speed": "Скорость",
"spentTime": "Затрачено времени: {time}",
"sshTermHelp": "Когда терминал можно прокручивать, горизонтальное перетаскивание позволяет выделить текст. Нажатие на кнопку клавиатуры включает/выключает клавиатуру. Иконка файла открывает текущий путь SFTP. Кнопка буфера обмена копирует содержимое, когда текст выделен, и вставляет содержимое из буфера обмена в терминал, когда текст не выделен, а в буфере есть содержимое. Иконка кода вставляет фрагменты кода в терминал и выполняет их.",
"sshTip": "Эта функция находится в стадии тестирования.\n\nПожалуйста, отправляйте отчеты о проблемах на {url} или присоединяйтесь к нашей разработке.",
"sshVirtualKeyAutoOff": "автоматическое отключение виртуальных клавиш",
"start": "старт",
"sshVirtualKeyAutoOff": "Автоматическое переключение виртуальных клавиш",
"start": "Старт",
"stat": "Статистика",
"stats": "статистика",
"stop": "остановить",
"stopped": "остановлено",
"stats": "Статистика",
"stop": "Остановить",
"stopped": "Остановлено",
"storage": "Хранение",
"supportFmtArgs": "Поддерживаются следующие форматы аргументов:",
"suspend": "приостановить",
"suspend": "Приостановить",
"suspendTip": "Функция приостановки требует прав root и поддержки systemd.",
"switchTo": "переключиться на {val}",
"switchTo": "Переключиться на {val}",
"sync": "Синхронизировать",
"syncTip": "Возможно, потребуется перезагрузка, чтобы некоторые изменения вступили в силу.",
"system": "система",
"tag": "тег",
"temperature": "температура",
"system": "Система",
"tag": "Теги",
"temperature": "Температура",
"termFontSizeTip": "Эта настройка повлияет на размер терминала (ширина и высота). Вы можете масштабировать страницу терминала, чтобы调整 размер шрифта текущей сессии.",
"terminal": "терминал",
"test": "тест",
"textScaler": "масштабирование текста",
"terminal": "Терминал",
"test": "Тест",
"textScaler": "Масштабирование текста",
"textScalerTip": "1.0 => 100% (исходный размер), применяется только к части шрифтов на странице сервера, изменение не рекомендуется.",
"theme": "тема",
"time": "время",
"times": "раз",
"total": "всего",
"traffic": "трафик",
"trySudo": "попробовать использовать sudo",
"theme": "Тема",
"time": "Время",
"times": "Раз",
"total": "Всего",
"traffic": "Трафик",
"trySudo": "Попробовать использовать sudo",
"ttl": "TTL",
"unknown": "неизвестно",
"unkownConvertMode": "неизвестный режим конвертации",
"update": "обновление",
"updateIntervalEqual0": "Если установлено в 0, статус сервера не будет автоматически обновляться.\nТакже не будет рассчитано использование CPU.",
"updateServerStatusInterval": "интервал обновления статуса сервера",
"upload": "загрузить",
"upsideDown": "перевернуть",
"uptime": "время работы",
"unknown": "Неизвестно",
"unkownConvertMode": "Неизвестный режим конвертации",
"update": "Обновление",
"updateIntervalEqual0": "Если установлено 0, статус сервера не будет автоматически обновляться.\nТакже не будет рассчитано использование ЦП.",
"updateServerStatusInterval": "Интервал обновления статуса сервера",
"upload": "Загрузить",
"upsideDown": "Перевернуть",
"uptime": "Время работы",
"useCdn": "Использование CDN",
"useCdnTip": "Не китайским пользователям рекомендуется использовать CDN. Хотели бы вы его использовать?",
"useNoPwd": "будет использоваться без пароля",
"usePodmanByDefault": "использовать Podman по умолчанию",
"used": "использовано",
"useNoPwd": "Будет использоваться без пароля",
"usePodmanByDefault": "Использовать Podman по умолчанию",
"used": "Использовано",
"view": "Вид",
"viewErr": "просмотр ошибок",
"viewErr": "Просмотр ошибок",
"virtKeyHelpClipboard": "Если в терминале выделен текст, то он копируется в буфер обмена, в противном случае содержимое буфера вставляется в терминал.",
"virtKeyHelpIME": "Включить/выключить клавиатуру",
"virtKeyHelpSFTP": "Открыть текущий путь в SFTP.",
@@ -217,9 +218,9 @@
"wakeLock": "Держать включенным",
"watchNotPaired": "Apple Watch не сопряжены",
"webdavSettingEmpty": "Настройки Webdav пусты",
"whenOpenApp": "при открытии приложения",
"whenOpenApp": "При открытии приложения",
"wolTip": "После настройки WOL (Wake-on-LAN) при каждом подключении к серверу отправляется запрос WOL.",
"write": "запись",
"writeScriptFailTip": "Запись в скрипт не удалась, возможно, из-за отсутствия прав или директории не существует.",
"write": "Запись",
"writeScriptFailTip": "Запись скрипта не удалась, возможно, из-за отсутствия прав или потому что, директории не существует.",
"writeScriptTip": "После подключения к серверу скрипт будет записан в ~/.config/server_box для мониторинга состояния системы. Вы можете проверить содержимое скрипта."
}

View File

@@ -142,6 +142,7 @@
"route": "Yönlendirme",
"run": "Çalıştır",
"running": "Çalışıyor",
"sameIdServerExist": "Aynı kimliğe sahip bir sunucu zaten var",
"save": "Kaydet",
"saved": "Kaydedildi",
"second": "s",

226
lib/l10n/app_uk.arb Normal file
View File

@@ -0,0 +1,226 @@
{
"@@locale": "uk",
"aboutThanks": "Дякуємо наступним особам, які взяли участь.",
"acceptBeta": "Прийняти оновлення бета-версії",
"addSystemPrivateKeyTip": "Наразі приватних ключів нема, хочете додати той, що йде з системою (~/.ssh/id_rsa)?",
"added2List": "Додано до списку завдань",
"addr": "Адреса",
"alreadyLastDir": "Вже в останньому каталозі.",
"authFailTip": "Авторизація не вдалася, будь ласка, перевірте правильність облікових даних",
"autoBackupConflict": "Тільки одне автоматичне резервне копіювання може бути активне одночасно.",
"autoConnect": "Авто підключення",
"autoRun": "Авто запуск",
"autoUpdateHomeWidget": "Автоматичне оновлення віджетів на головному екрані",
"backupTip": "Експортовані дані слабо зашифровані. \nБудь ласка, зберігайте їх у безпеці.",
"backupVersionNotMatch": "Версія резервного копіювання не збіглася.",
"battery": "Акумулятор",
"bgRun": "Запуск у фоновому режимі",
"bgRunTip": "Цей перемикач лише вказує на те, що програма намагатиметься працювати у фоновому режимі. Чи може вона працювати у фоновому режимі, залежить від прав доступу. Для AOSP-орієнтованих Android ROM, будь ласка, вимкніть \"Оптимізацію акумулятора\" в цьому додатку. Для MIUI / HyperOS, будь ласка, змініть політику економії енергії на \"Нескінченна\".",
"cmd": "Команда",
"collapseUITip": "Сховати довгі списки, що є у UI за замовчуванням",
"conn": "З'єднання",
"container": "Контейнер",
"containerTrySudoTip": "Наприклад: У застосунку користувач це aaa, але Docker встановлений під користувачем root. У цьому випадку вам потрібно активувати цю опцію.",
"convert": "Конвертувати",
"copyPath": "Скопіювати шлях",
"cpuViewAsProgressTip": "Відобразити використання кожного процесора у вигляді стовпчикової діаграми (старий стиль)",
"cursorType": "Тип курсора",
"customCmd": "Користувацькі команди",
"customCmdDocUrl": "https://github.com/lollipopkit/flutter_server_box/wiki#custom-commands",
"customCmdHint": "\"Ім'я Команди\": \"Команда\"",
"decode": "Декодувати",
"decompress": "Розпакувати",
"deleteServers": "Масове видалення серверів",
"dirEmpty": "Переконайтеся, що директорія пуста.",
"disconnected": "Відключено",
"disk": "Диск",
"diskIgnorePath": "Ігнорувати шлях для диска",
"displayCpuIndex": "Відобразити індекс ЦП",
"dl2Local": "Завантажити {fileName} на локальний комп'ютер?",
"dockerEmptyRunningItems": "Немає запущених контейнерів.\nЦе може бути через:\n- Користувача Docker, відмінного від користувача, налаштованого в додатку\n- змінну оточення DOCKER_HOST, яка не була правильно зчитана. Ви можете виконати `echo $DOCKER_HOST` у терміналі, щоб побачити її значення.",
"dockerImagesFmt": "Всього {count} образів",
"dockerNotInstalled": "Docker не встановлено",
"dockerStatusRunningAndStoppedFmt": "{runningCount} запущено, {stoppedCount} контейнерів зупинено.",
"dockerStatusRunningFmt": "{count} контейнер(и) запущено.",
"doubleColumnMode": "Режим подвійної колонки",
"doubleColumnTip": "Ця опція лише активує функцію, чи можна її насправді включити, залежить від ширини пристрою",
"editVirtKeys": "Редагувати віртуальні клавіші",
"editor": "Редактор",
"editorHighlightTip": "Поточна підсвітка коду не ідеальна і може бути вимкнена для покращення.",
"encode": "Кодувати",
"envVars": "Змінні середовища",
"experimentalFeature": "Експериментальна функція",
"extraArgs": "Додаткові аргументи",
"fallbackSshDest": "Резервна SSH адреса",
"fdroidReleaseTip": "Якщо ви завантажили цей застосунок з F-Droid, рекомендується відключити цю опцію.",
"fileTooLarge": "Файл '{file}' занадто великий ({size}), макс {sizeMax}",
"followSystem": "Слідувати системі",
"font": "Шрифт",
"fontSize": "Розмір шрифту",
"force": "Примусово",
"fullScreen": "Повноекранний режим",
"fullScreenJitter": "Тремтіння в повноекранному режимі",
"fullScreenJitterHelp": "Щоб уникнути вигоряння екрану",
"fullScreenTip": "Чи слід увімкнути повноекранний режим під час повороту пристрою в горизонтальне положення? Ця опція стосується лише вкладки сервера.",
"goBackQ": "Повернутися назад?",
"goto": "Перейти до",
"hideTitleBar": "Сховати заголовок",
"highlight": "Підсвітка коду",
"homeWidgetUrlConfig": "Налаштувати URL віджета на головному екрані",
"host": "Хост",
"httpFailedWithCode": "Запит не вдався, код статусу: {code}",
"ignoreCert": "Ігнорувати сертифікат",
"image": "Зображення",
"imagesList": "Список зображень",
"init": "Ініціалізувати",
"inner": "Внутрішній",
"install": "Встановити",
"installDockerWithUrl": "Будь ласка, спочатку встановіть Docker. (https://docs.docker.com/engine/install)",
"invalid": "Недійсний",
"jumpServer": "Стрибковий Сервер",
"keepForeground": "Тримати застосунок на передньому плані!",
"keepStatusWhenErr": "Зберегати останній стан сервера",
"keepStatusWhenErrTip": "Тільки в разі виникнення помилки під час виконання скрипту",
"keyAuth": "Аутентифікація ключем",
"letterCache": "Кешування букв",
"letterCacheTip": "Рекомендується відключити, але після вимкнення стане неможливим введення CJK (китайських, японських, корейських) символів.",
"license": "Ліцензія",
"location": "Місцезнаходження",
"loss": "втрата пакетів",
"madeWithLove": "Зроблено з ❤️ від {myGithub}",
"manual": "Посібник",
"max": "макс.",
"maxRetryCount": "Кількість повторних спроб підключення до сервера",
"maxRetryCountEqual0": "Знову і знову буде намагатися повторно підключитися.",
"min": "мін.",
"mission": "Місія",
"more": "Більше",
"moveOutServerFuncBtnsHelp": "Включено: може відображатися під кожною карткою на вкладці Сервер. Вимкнено: може відображатися вгорі на сторінці деталей сервера.",
"ms": "мс.",
"needHomeDir": "Якщо ви користувач Synology, [дивіться тут](https://kb.synology.com/DSM/tutorial/user_enable_home_service). Користувачі інших систем повинні знайти інформацію про те, як створити домашній каталог.",
"needRestart": "Необхідно перезапустити застосунок",
"net": "Мережа",
"netViewType": "Тип перегляду мережі",
"newContainer": "Новий контейнер",
"noLineChart": "Не використовувати лінійні діаграми",
"noLineChartForCpu": "Не використовувати лінійні діаграми для ЦП",
"noPrivateKeyTip": "Приватного ключа немає, можливо, він був видалений або сталася помилка конфігурації.",
"noPromptAgain": "Більше не запитувати",
"node": "Вузол",
"notAvailable": "Недоступний",
"onServerDetailPage": "На сторінці деталі сервера",
"onlyOneLine": "Відображати лише в один рядок (прокрутка)",
"onlyWhenCoreBiggerThan8": "Працює лише тоді, коли кількість ядер перевищує 8",
"openLastPath": "Відкрити останній шлях",
"openLastPathTip": "Для різних серверів будуть збережені різні логи. Записується шлях при виході",
"parseContainerStatsTip": "Парсинг статусу зайнятості Docker є відносно повільним.",
"percentOfSize": "{percent}% з {size}",
"permission": "Дозволи",
"pingAvg": "Середнє:",
"pingInputIP": "Будь ласка, введіть цільовий IP / Домен.",
"pingNoServer": "Немає сервера для пінгування.\nБудь ласка, додайте сервер у вкладці `Сервер`.",
"pkg": "Пакет",
"plugInType": "Тип вставки",
"port": "Порт",
"preview": "Попередній перегляд",
"privateKey": "Приватний ключ",
"process": "Процес",
"pushToken": "Надіслати токен",
"pveIgnoreCertTip": "Не рекомендується включати, будьте обережні з ризиками безпеки! Якщо ви використовуєте стандартний сертифікат від PVE, вам потрібно увімкнути цю опцію.",
"pveLoginFailed": "Не вдалося увійти. Неможливо пройти аутентифікацію за допомогою імені користувача/пароля з конфігурації сервера для входу Linux PAM.",
"pveVersionLow": "Ця функція наразі перебуває на стадії тестування та випробувалася лише на PVE 8+. Будь ласка, використовуйте її з обережністю.",
"pwd": "Пароль",
"read": "Читати",
"reboot": "Перезавантажити",
"rememberPwdInMem": "Запам'ятати пароль у пам'яті",
"rememberPwdInMemTip": "Використовується для контейнерів, призупинення тощо.",
"rememberWindowSize": "Запам'ятати розмір вікна",
"remotePath": "Віддалений шлях",
"restart": "Перезапустити",
"result": "Результат",
"rotateAngel": "Кут повороту",
"route": "Маршрут",
"run": "Запустити",
"running": "Виконання",
"sameIdServerExist": "Сервер з таким ID вже існує",
"save": "Зберегти",
"saved": "Збережено",
"second": "сек.",
"sensors": "Датчики",
"sequence": "Послідовність",
"server": "Сервер",
"serverDetailOrder": "Порядок віджетів на сторінці деталі",
"serverFuncBtns": "Кнопки функцій сервера",
"serverOrder": "Порядок сервера",
"sftpDlPrepare": "Підготовка до підключення...",
"sftpEditorTip": "Якщо порожньо, використовуйте вбудований редактор файлів програми. Якщо є значення, використовуйте редактор віддаленого сервера, наприклад, `vim` (рекомендується автоматично визначити відповідно до `EDITOR`).",
"sftpRmrDirSummary": "Використовуйте `rm -r`, щоб видалити папку в SFTP.",
"sftpSSHConnected": "SFTP підключено",
"sftpShowFoldersFirst": "Спочатку відображати директорії",
"showDistLogo": "Показати логотип дистрибутива",
"shutdown": "Вимкнення",
"size": "Розмір",
"snippet": "Фрагмент",
"softWrap": "М'ягкий перенос",
"specifyDev": "Вказати пристрій",
"specifyDevTip": "Наприклад, статистика мережевого трафіку за замовчуванням є для всіх пристроїв. Ви можете вказати певний пристрій тут.",
"speed": "Швидкість",
"spentTime": "Витрачений час: {time}",
"sshTermHelp": "Коли термінал прокрутний, горизонтальне проведення вибирає текст. Натискання кнопки клавіатури вмикає/вимикає клавіатуру. Іконка файлу відкриває поточний шлях SFTP. Кнопка буфера обміну копіює вміст, коли текст вибрано, і вставляє вміст з буфера обміну в термінал, коли текст не вибрано і є вміст у буфері обміну. Іконка коду вставляє фрагменти коду в термінал і виконує їх.",
"sshTip": "Ця функція наразі в експериментальній стадії. Будь ласка, повідомте про помилки за адресою {url} або приєднуйтеся до нашої розробки.",
"sshVirtualKeyAutoOff": "Автоматичне переключення віртуальних клавіш",
"start": "Старт",
"stat": "Статистика",
"stats": "Статистики",
"stop": "Зупинити",
"stopped": "Зупинено",
"storage": "Сховище",
"supportFmtArgs": "Підтримуються такі параметри форматування:",
"suspend": "Призупинити",
"suspendTip": "Функція призупинення потребує адміністративних прав та підтримки systemd.",
"switchTo": "Переключитися на {val}",
"sync": "Синхронізація",
"syncTip": "Може знадобитися перезапуск, щоб деякі зміни набрали чинності.",
"system": "Система",
"tag": "Теги",
"temperature": "Температура",
"termFontSizeTip": "Це налаштування вплине на розмір терміналу (ширину та висоту). Ви можете масштабувати на сторінці терміналу, щоб налаштувати розмір шрифту поточної сесії.",
"terminal": "Термінал",
"test": "Тест",
"textScaler": "Масштабування тексту",
"textScalerTip": "1.0 => 100% (оригінальний розмір), працює лише на частині шрифта сторінки сервера, не рекомендується змінювати.",
"theme": "Тема",
"time": "Час",
"times": "Рази",
"total": "Всього",
"traffic": "Трафік",
"trySudo": "Спробуйте використовувати sudo",
"ttl": "TTL",
"unknown": "Невідомо",
"unkownConvertMode": "Невідомий режим конвертації",
"update": "Оновити",
"updateIntervalEqual0": "Ви встановили 0, автоматичне оновлення не відбудеться.\nНе можна розрахувати статус ЦП.",
"updateServerStatusInterval": "Інтервал оновлення статусу сервера",
"upload": "Завантаження",
"upsideDown": "Доверху дном",
"uptime": "Час роботи",
"useCdn": "Використання CDN",
"useCdnTip": "Нереспонсивним користувачам рекомендується використовувати CDN. Чи хочете ви його використовувати?",
"useNoPwd": "Пароль не буде використовуватися",
"usePodmanByDefault": "Використовувати Podman за замовчуванням",
"used": "Використано",
"view": "Переглянути",
"viewErr": "Переглянути помилку",
"virtKeyHelpClipboard": "Копіювати в буфер обміну, якщо вибраний термінал не порожній, в іншому випадку вставити вміст буфера обміну в термінал.",
"virtKeyHelpIME": "Увімкнути/вимкнути клавіатуру",
"virtKeyHelpSFTP": "Відкрити поточний каталог у SFTP.",
"waitConnection": "Будь ласка, зачекайте, доки з'єднання буде встановлено.",
"wakeLock": "Залишити активним",
"watchNotPaired": "Немає спарованого Apple Watch",
"webdavSettingEmpty": "Налаштування WebDav порожнє",
"whenOpenApp": "При відкритті програми",
"wolTip": "Після налаштування WOL (Wake-on-LAN), при кожному підключенні до сервера відправляється запит WOL.",
"write": "Записати",
"writeScriptFailTip": "Запис у скрипт не вдався, можливо, через брак дозволів або каталог не існує.",
"writeScriptTip": "Після підключення до сервера скрипт буде записано у ~/.config/server_box для моніторингу стану системи. Ви можете переглянути вміст скрипта."
}

View File

@@ -142,6 +142,7 @@
"route": "路由",
"run": "运行",
"running": "运行中",
"sameIdServerExist": "已存在相同 id 的服务器",
"save": "保存",
"saved": "已保存",
"second": "秒",

View File

@@ -142,6 +142,7 @@
"route": "路由",
"run": "運行",
"running": "運作中",
"sameIdServerExist": "已存在相同 ID 的伺服器",
"save": "保存",
"saved": "已保存",
"second": "秒",

View File

@@ -9,8 +9,7 @@ import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:logging/logging.dart';
import 'package:server_box/app.dart';
import 'package:server_box/core/utils/sync/icloud.dart';
import 'package:server_box/core/utils/sync/webdav.dart';
import 'package:server_box/core/sync.dart';
import 'package:server_box/data/model/app/menu/server_func.dart';
import 'package:server_box/data/model/app/net_view.dart';
import 'package:server_box/data/model/app/server_detail_card.dart';
@@ -25,8 +24,8 @@ import 'package:server_box/data/provider/server.dart';
import 'package:server_box/data/provider/sftp.dart';
import 'package:server_box/data/provider/snippet.dart';
import 'package:server_box/data/res/build_data.dart';
import 'package:server_box/data/res/misc.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/data/store/no_backup.dart';
Future<void> main() async {
_runInZone(() async {
@@ -52,7 +51,7 @@ void _runInZone(void Function() body) {
Future<void> _initApp() async {
WidgetsFlutterBinding.ensureInitialized();
await Paths.init(BuildData.name, bakName: Miscs.bakFileName);
await Paths.init(BuildData.name, bakName: 'srvbox_bak.json');
await _initData();
_setupDebug();
@@ -67,7 +66,6 @@ Future<void> _initApp() async {
FontUtils.loadFrom(Stores.setting.fontPath.fetch());
_doPlatformRelated();
_doVersionRelated();
}
Future<void> _initData() async {
@@ -93,6 +91,9 @@ Future<void> _initData() async {
SftpProvider.instance.load();
if (Stores.setting.betaTest.fetch()) AppUpdate.chan = AppUpdateChan.beta;
// It may effect the following logic, so await it.
await _doVersionRelated();
}
void _setupDebug() {
@@ -115,10 +116,7 @@ void _doPlatformRelated() async {
// Plus 1 to avoid 0.
Computer.shared.turnOn(workersCount: (serversCount / 3).round() + 1);
if (isIOS || isMacOS) {
if (Stores.setting.icloudSync.fetch()) ICloud.sync();
}
if (Stores.setting.webdavSync.fetch()) Webdav.sync();
bakSync.sync();
}
// It may contains some async heavy funcs.
@@ -130,6 +128,7 @@ Future<void> _doVersionRelated() async {
if (curVer < newVer) {
ServerDetailCards.autoAddNewCards(newVer);
ServerFuncBtn.autoAddNewFuncs(newVer);
NoBackupStore.instance.migrate();
Stores.setting.lastVer.put(newVer);
}
}

View File

@@ -5,8 +5,7 @@ import 'package:computer/computer.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/utils/sync/icloud.dart';
import 'package:server_box/core/utils/sync/webdav.dart';
import 'package:server_box/core/sync.dart';
import 'package:server_box/data/model/app/backup.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/snippet.dart';
@@ -14,17 +13,32 @@ import 'package:server_box/data/provider/snippet.dart';
import 'package:server_box/data/res/misc.dart';
import 'package:server_box/data/res/store.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/data/store/no_backup.dart';
final icloudLoading = false.vn;
final webdavLoading = false.vn;
class BackupPage extends StatelessWidget {
class BackupPage extends StatefulWidget {
const BackupPage({super.key});
@override
State<BackupPage> createState() => _BackupPageState();
}
final class _BackupPageState extends State<BackupPage>
with AutomaticKeepAliveClientMixin {
final _noBak = NoBackupStore.instance;
final icloudLoading = false.vn;
final webdavLoading = false.vn;
@override
void dispose() {
icloudLoading.dispose();
webdavLoading.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
appBar: CustomAppBar(title: Text(libL10n.backup)),
body: _buildBody(context),
);
}
@@ -91,9 +105,9 @@ class BackupPage extends StatelessWidget {
leading: const Icon(Icons.cloud),
title: const Text('iCloud'),
trailing: StoreSwitch(
prop: Stores.setting.icloudSync,
prop: _noBak.icloudSync,
validator: (p0) {
if (p0 && Stores.setting.webdavSync.fetch()) {
if (p0 && _noBak.webdavSync.fetch()) {
context.showSnackBar(l10n.autoBackupConflict);
return false;
}
@@ -102,7 +116,7 @@ class BackupPage extends StatelessWidget {
callback: (val) async {
if (val) {
icloudLoading.value = true;
await ICloud.sync();
await bakSync.sync(rs: icloud);
icloudLoading.value = false;
}
},
@@ -126,17 +140,17 @@ class BackupPage extends StatelessWidget {
ListTile(
title: Text(libL10n.auto),
trailing: StoreSwitch(
prop: Stores.setting.webdavSync,
prop: _noBak.webdavSync,
validator: (p0) {
if (p0) {
if (Stores.setting.webdavUrl.fetch().isEmpty ||
Stores.setting.webdavUser.fetch().isEmpty ||
Stores.setting.webdavPwd.fetch().isEmpty) {
if (_noBak.webdavUrl.fetch().isEmpty ||
_noBak.webdavUser.fetch().isEmpty ||
_noBak.webdavPwd.fetch().isEmpty) {
context.showSnackBar(l10n.webdavSettingEmpty);
return false;
}
}
if (Stores.setting.icloudSync.fetch()) {
if (_noBak.icloudSync.fetch()) {
context.showSnackBar(l10n.autoBackupConflict);
return false;
}
@@ -145,7 +159,7 @@ class BackupPage extends StatelessWidget {
callback: (val) async {
if (val) {
webdavLoading.value = true;
await Webdav.sync();
await bakSync.sync(rs: webdav);
webdavLoading.value = false;
}
},
@@ -156,7 +170,7 @@ class BackupPage extends StatelessWidget {
trailing: ListenableBuilder(
listenable: webdavLoading,
builder: (_, __) {
if (webdavLoading.value) return SizedLoading.centerSmall;
if (webdavLoading.value) return SizedLoading.small;
return Row(
mainAxisSize: MainAxisSize.min,
@@ -298,7 +312,7 @@ class BackupPage extends StatelessWidget {
)),
actions: Btn.ok(
onTap: () async {
await backup.restore(force: true);
await backup.merge(force: true);
context.pop();
},
).toList,
@@ -312,7 +326,7 @@ class BackupPage extends StatelessWidget {
Future<void> _onTapWebdavDl(BuildContext context) async {
webdavLoading.value = true;
try {
final files = await Webdav.list();
final files = await webdav.list();
if (files.isEmpty) return context.showSnackBar(l10n.dirEmpty);
final fileName = await context.showPickSingleDialog(
@@ -321,13 +335,10 @@ class BackupPage extends StatelessWidget {
);
if (fileName == null) return;
final result = await Webdav.download(relativePath: fileName);
if (result != null) {
throw result;
}
await webdav.download(relativePath: fileName);
final dlFile = await File('${Paths.doc}/$fileName').readAsString();
final dlBak = await Computer.shared.start(Backup.fromJsonString, dlFile);
await dlBak.restore(force: true);
await dlBak.merge(force: true);
} catch (e, s) {
context.showErrDialog(e, s, libL10n.restore);
Loggers.app.warning('Download webdav backup failed', e, s);
@@ -342,10 +353,7 @@ class BackupPage extends StatelessWidget {
final bakName = '$date-${Miscs.bakFileName}';
try {
await Backup.backup(bakName);
final uploadResult = await Webdav.upload(relativePath: bakName);
if (uploadResult != null) {
throw uploadResult;
}
await webdav.upload(relativePath: bakName);
Loggers.app.info('Upload webdav backup success');
} catch (e, s) {
context.showErrDialog(e, s, l10n.upload);
@@ -356,9 +364,9 @@ class BackupPage extends StatelessWidget {
}
Future<void> _onTapWebdavSetting(BuildContext context) async {
final url = TextEditingController(text: Stores.setting.webdavUrl.fetch());
final user = TextEditingController(text: Stores.setting.webdavUser.fetch());
final pwd = TextEditingController(text: Stores.setting.webdavPwd.fetch());
final url = TextEditingController(text: _noBak.webdavUrl.fetch());
final user = TextEditingController(text: _noBak.webdavUser.fetch());
final pwd = TextEditingController(text: _noBak.webdavPwd.fetch());
final nodeUser = FocusNode();
final nodePwd = FocusNode();
final result = await context.showRoundDialog<bool>(
@@ -392,13 +400,18 @@ class BackupPage extends StatelessWidget {
actions: Btnx.oks,
);
if (result == true) {
final result = await Webdav.test(url.text, user.text, pwd.text);
if (result != null) {
context.showSnackBar(result);
return;
try {
await Webdav.test(url.text, user.text, pwd.text);
context.showSnackBar(libL10n.success);
webdav.init(WebdavInitArgs(
url: url.text,
user: user.text,
pwd: pwd.text,
prefix: 'serverbox/',
));
} catch (e, s) {
context.showErrDialog(e, s, 'Webdav');
}
context.showSnackBar(libL10n.success);
Webdav.changeClient(url.text, user.text, pwd.text);
}
}
@@ -427,7 +440,7 @@ class BackupPage extends StatelessWidget {
)),
actions: Btn.ok(
onTap: () async {
await backup.restore(force: true);
await backup.merge(force: true);
context.pop();
},
).toList,
@@ -476,4 +489,7 @@ class BackupPage extends StatelessWidget {
Loggers.app.warning('Import servers failed', e, s);
}
}
@override
bool get wantKeepAlive => true;
}

View File

@@ -38,6 +38,7 @@ class _ContainerPageState extends State<ContainerPage> {
void dispose() {
super.dispose();
_textController.dispose();
_container.dispose();
}
@override

View File

@@ -14,7 +14,17 @@ import 'package:server_box/data/res/store.dart';
import 'package:server_box/view/widget/two_line_text.dart';
class EditorPage extends StatefulWidget {
final class EditorPageRet {
/// If edit text, this includes the edited result
final String? result;
/// Indicates whether it's ok to edit existing file
final bool? editExistedOk;
const EditorPageRet({this.result, this.editExistedOk});
}
final class EditorPageArgs {
/// If path is not null, then it's a file editor
/// If path is null, then it's a text editor
final String? path;
@@ -28,13 +38,23 @@ class EditorPage extends StatefulWidget {
final String? title;
const EditorPage({
super.key,
const EditorPageArgs({
this.path,
this.text,
this.langCode,
this.title,
});
}
class EditorPage extends StatefulWidget {
final EditorPageArgs? args;
const EditorPage({super.key, this.args});
static const route = AppRoute<EditorPageRet, EditorPageArgs>(
page: EditorPage.new,
path: '/editor',
);
@override
State<EditorPage> createState() => _EditorPageState();
@@ -50,13 +70,21 @@ class _EditorPageState extends State<EditorPage> {
String? _langCode;
@override
void dispose() {
super.dispose();
_controller.dispose();
_focusNode.dispose();
}
@override
void initState() {
super.initState();
/// Higher priority than [path]
if (Stores.setting.editorHighlight.fetch()) {
_langCode = widget.langCode ?? Highlights.getCode(widget.path);
_langCode =
widget.args?.langCode ?? Highlights.getCode(widget.args?.path);
}
_controller = CodeController(
language: Highlights.all[_langCode],
@@ -72,14 +100,15 @@ class _EditorPageState extends State<EditorPage> {
}
Future<void> _setupCtrl() async {
if (widget.path != null) {
final code = await Computer.shared.start(
(path) async => await File(path).readAsString(),
widget.path!,
final path = widget.args?.path;
final text = widget.args?.text;
if (path != null) {
final code = await Computer.shared.startNoParam(
() => File(path).readAsString(),
);
_controller.text = code;
} else if (widget.text != null) {
_controller.text = widget.text!;
} else if (text != null) {
_controller.text = text;
}
}
@@ -97,13 +126,6 @@ class _EditorPageState extends State<EditorPage> {
_focusNode.requestFocus();
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -117,7 +139,9 @@ class _EditorPageState extends State<EditorPage> {
return CustomAppBar(
centerTitle: true,
title: TwoLineText(
up: widget.title ?? widget.path?.getFileName() ?? l10n.unknown,
up: widget.args?.title ??
widget.args?.path?.getFileName() ??
l10n.unknown,
down: l10n.editor,
),
actions: [
@@ -144,20 +168,21 @@ class _EditorPageState extends State<EditorPage> {
onPressed: () async {
// If path is not null, then it's a file editor
// save the text and return true to pop the page
if (widget.path != null) {
final path = widget.args?.path;
if (path != null) {
final (res, _) = await context.showLoadingDialog(
fn: () => File(widget.path!).writeAsString(_controller.text),
fn: () => File(path).writeAsString(_controller.text),
);
if (res == null) {
context.showSnackBar(libL10n.fail);
return;
}
context.pop(true);
context.pop(const EditorPageRet(editExistedOk: true));
return;
}
// else it's a text editor
// return the text to the previous page
context.pop(_controller.text);
context.pop(EditorPageRet(result: _controller.text));
},
)
],

View File

@@ -1,39 +1,20 @@
part of 'home.dart';
final class _AppBar extends CustomAppBar {
final ValueNotifier<int> selectIndex;
final ValueNotifier<bool> landscape;
final class _AppBar extends StatelessWidget implements PreferredSizeWidget {
final double paddingTop;
const _AppBar({
required this.selectIndex,
required this.landscape,
super.title,
super.actions,
super.centerTitle,
});
const _AppBar(this.paddingTop);
@override
Widget build(BuildContext context) {
final placeholder = SizedBox(
height: CustomAppBar.barHeight ?? 0 + MediaQuery.of(context).padding.top,
);
return selectIndex.listenVal(
(idx) {
if (idx == AppTab.ssh.index) {
return placeholder;
}
if (isDesktop) return super.build(context);
return ValBuilder(
listenable: landscape,
builder: (ls) {
if (ls) return placeholder;
return super.build(context);
},
);
},
return SizedBox(
height: paddingTop,
child: isIOS
? const Center(child: Text(BuildData.name, style: UIs.text15Bold))
: null,
);
}
@override
Size get preferredSize => Size.fromHeight(paddingTop);
}

View File

@@ -1,18 +1,10 @@
import 'dart:convert';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/core/channel/home_widget.dart';
import 'package:server_box/core/extension/build.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/route.dart';
import 'package:server_box/data/model/app/tab.dart';
import 'package:server_box/data/provider/app.dart';
import 'package:server_box/data/provider/server.dart';
import 'package:server_box/data/res/build_data.dart';
import 'package:server_box/data/res/github_id.dart';
import 'package:server_box/data/res/misc.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/data/res/url.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@@ -39,6 +31,18 @@ class _HomePageState extends State<HomePage>
bool _switchingPage = false;
bool _shouldAuth = false;
@override
void dispose() {
super.dispose();
WidgetsBinding.instance.removeObserver(this);
ServerProvider.closeServer();
_pageController.dispose();
WakelockPlus.disable();
_selectIndex.dispose();
_isLandscape.dispose();
}
@override
void initState() {
super.initState();
@@ -61,15 +65,6 @@ class _HomePageState extends State<HomePage>
MediaQuery.of(context).orientation == Orientation.landscape;
}
@override
void dispose() {
super.dispose();
WidgetsBinding.instance.removeObserver(this);
ServerProvider.closeServer();
_pageController.dispose();
WakelockPlus.disable();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
@@ -105,39 +100,10 @@ class _HomePageState extends State<HomePage>
Widget build(BuildContext context) {
super.build(context);
AppProvider.ctx = context;
final sysPadding = MediaQuery.of(context).padding;
final appBar = _AppBar(
selectIndex: _selectIndex,
landscape: _isLandscape,
centerTitle: false,
title: const Text(BuildData.name),
actions: <Widget>[
ValBuilder(
listenable: Stores.setting.serverStatusUpdateInterval.listenable(),
builder: (interval) {
if (interval != 0) return UIs.placeholder;
return IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
onPressed: () async {
await ServerProvider.refresh();
},
);
},
),
IconButton(
icon: const Icon(Icons.developer_mode, size: 21),
tooltip: 'Debug',
onPressed: () => DebugPage.route.go(
context,
args: const DebugPageArgs(title: 'Debug(${BuildData.build})'),
),
),
],
);
return Scaffold(
drawer: _buildDrawer(),
appBar: appBar,
appBar: _AppBar(sysPadding.top),
body: PageView.builder(
controller: _pageController,
itemCount: AppTab.values.length,
@@ -185,133 +151,7 @@ class _HomePageState extends State<HomePage>
labelBehavior: ls
? NavigationDestinationLabelBehavior.alwaysHide
: NavigationDestinationLabelBehavior.onlyShowSelected,
destinations: [
NavigationDestination(
icon: const Icon(BoxIcons.bx_server),
label: l10n.server,
selectedIcon: const Icon(BoxIcons.bxs_server),
),
const NavigationDestination(
icon: Icon(Icons.terminal_outlined),
label: 'SSH',
selectedIcon: Icon(Icons.terminal),
),
NavigationDestination(
icon: const Icon(MingCute.file_code_line),
label: l10n.snippet,
selectedIcon: const Icon(MingCute.file_code_fill),
),
const NavigationDestination(
icon: Icon(MingCute.planet_line),
label: 'Ping',
selectedIcon: Icon(MingCute.planet_fill),
),
],
);
}
Widget _buildDrawer() {
return Drawer(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildIcon(),
const Text(
'${BuildData.name}\n${BuildDataX.versionStr}',
textAlign: TextAlign.center,
style: UIs.text15,
),
const SizedBox(height: 37),
_buildTiles(),
],
),
);
}
Widget _buildTiles() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 17),
child: Column(
children: [
ListTile(
leading: const Icon(Icons.settings),
title: Text(libL10n.setting),
onTap: () => AppRoutes.settings().go(context),
onLongPress: _onLongPressSetting,
),
ListTile(
leading: const Icon(Icons.vpn_key),
title: Text(l10n.privateKey),
onTap: () => AppRoutes.keyList().go(context),
),
ListTile(
leading: const Icon(BoxIcons.bxs_file_blank),
title: Text(libL10n.file),
onTap: () => AppRoutes.localStorage().go(context),
),
ListTile(
leading: const Icon(MingCute.file_import_fill),
title: Text(libL10n.backup),
onTap: () => AppRoutes.backup().go(context),
),
ListTile(
leading: const Icon(OctIcons.feed_discussion),
title: Text('${libL10n.about} & ${libL10n.feedback}'),
onTap: _showAboutDialog,
)
].map((e) => CardX(child: e)).toList(),
),
);
}
void _showAboutDialog() {
context.showRoundDialog(
title: libL10n.about,
child: _buildAboutContent(),
actions: [
TextButton(
onPressed: () => Urls.appWiki.launch(),
child: const Text('Wiki'),
),
TextButton(
onPressed: () => Urls.appHelp.launch(),
child: Text(libL10n.feedback),
),
TextButton(
onPressed: () => showLicensePage(context: context),
child: Text(l10n.license),
),
],
);
}
Widget _buildAboutContent() {
return SingleChildScrollView(
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.8,
child: SimpleMarkdown(
data: '''
${l10n.madeWithLove('[lollipopkit](${Urls.myGithub})')}
#### Contributors
${GithubIds.contributors.map((e) => '[$e](${e.url})').join(' ')}
#### Participants
${GithubIds.participants.map((e) => '[$e](${e.url})').join(' ')}
#### My other apps
- [GPT Box](https://github.com/lollipopkit/flutter_gpt_box)
''',
),
),
);
}
Widget _buildIcon() {
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 57, maxWidth: 57),
child: UIs.appIcon,
destinations: AppTab.navDestinations,
);
}
@@ -367,35 +207,4 @@ ${GithubIds.participants.map((e) => '[$e](${e.url})').join(' ')}
);
}
}
Future<void> _onLongPressSetting() async {
final map = Stores.setting.box.toJson(includeInternal: false);
final keys = map.keys;
/// Encode [map] to String with indent `\t`
final text = Miscs.jsonEncoder.convert(map);
final result = await AppRoutes.editor(
text: text,
langCode: 'json',
title: libL10n.setting,
).go<String>(context);
if (result == null) {
return;
}
try {
final newSettings = json.decode(result) as Map<String, dynamic>;
Stores.setting.box.putAll(newSettings);
final newKeys = newSettings.keys;
final removedKeys = keys.where((e) => !newKeys.contains(e));
for (final key in removedKeys) {
Stores.setting.box.delete(key);
}
} catch (e, trace) {
context.showRoundDialog(
title: libL10n.error,
child: Text('${l10n.save}:\n$e'),
);
Loggers.app.warning('Update json settings failed', e, trace);
}
}
}

View File

@@ -16,6 +16,13 @@ class _IPerfPageState extends State<IPerfPage> {
final _hostCtrl = TextEditingController();
final _portCtrl = TextEditingController();
@override
void dispose() {
super.dispose();
_hostCtrl.dispose();
_portCtrl.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(

View File

@@ -23,12 +23,6 @@ class _PingPageState extends State<PingPage>
final _results = ValueNotifier(<PingResult>[]);
bool get isInit => _results.value.isEmpty;
@override
void initState() {
super.initState();
_textEditingController = TextEditingController(text: '');
}
@override
void dispose() {
super.dispose();
@@ -36,6 +30,12 @@ class _PingPageState extends State<PingPage>
_results.dispose();
}
@override
void initState() {
super.initState();
_textEditingController = TextEditingController(text: '');
}
@override
Widget build(BuildContext context) {
super.build(context);

View File

@@ -34,6 +34,18 @@ class _PrivateKeyEditPageState extends State<PrivateKeyEditPage> {
final _loading = ValueNotifier<Widget?>(null);
@override
void dispose() {
super.dispose();
_nameController.dispose();
_keyController.dispose();
_pwdController.dispose();
_nameNode.dispose();
_keyNode.dispose();
_pwdNode.dispose();
_loading.dispose();
}
@override
void initState() {
super.initState();
@@ -51,17 +63,6 @@ class _PrivateKeyEditPageState extends State<PrivateKeyEditPage> {
}
}
@override
void dispose() {
super.dispose();
_nameController.dispose();
_keyController.dispose();
_pwdController.dispose();
_nameNode.dispose();
_keyNode.dispose();
_pwdNode.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -199,7 +200,7 @@ class _PrivateKeyEditPageState extends State<PrivateKeyEditPage> {
return;
}
FocusScope.of(context).unfocus();
_loading.value = SizedLoading.centerMedium;
_loading.value = SizedLoading.medium;
try {
final decrypted = await Computer.shared.start(decyptPem, [key, pwd]);
final pki = PrivateKeyInfo(id: name, key: decrypted);

View File

@@ -22,9 +22,6 @@ class _PrivateKeyListState extends State<PrivateKeysListPage>
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: CustomAppBar(
title: Text(l10n.privateKey),
),
body: _buildBody(),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),

View File

@@ -34,6 +34,12 @@ class _ProcessPageState extends State<ProcessPage> {
ProcSortMode _procSortMode = ProcSortMode.cpu;
List<ProcSortMode> _sortModes = List.from(ProcSortMode.values);
@override
void dispose() {
super.dispose();
_timer.cancel();
}
@override
void initState() {
super.initState();
@@ -75,12 +81,6 @@ class _ProcessPageState extends State<ProcessPage> {
}
}
@override
void dispose() {
super.dispose();
_timer.cancel();
}
@override
Widget build(BuildContext context) {
final actions = <Widget>[

View File

@@ -29,6 +29,13 @@ final class _PvePageState extends State<PvePage> {
late MediaQueryData _media;
Timer? _timer;
@override
void dispose() {
super.dispose();
_timer?.cancel();
_pve.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -42,13 +49,6 @@ final class _PvePageState extends State<PvePage> {
_afterInit();
}
@override
void dispose() {
super.dispose();
_timer?.cancel();
_pve.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(

View File

@@ -17,6 +17,7 @@ import 'package:server_box/data/model/server/sensors.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/view/page/server/edit.dart';
import 'package:server_box/view/widget/server_func_btns.dart';
import 'package:server_box/core/route.dart';
@@ -61,6 +62,12 @@ class _ServerDetailPageState extends State<ServerDetailPage>
late final _collapse = _settings.collapseUIDefault.fetch();
late final _textFactor = TextScaler.linear(_settings.textFactor.fetch());
@override
void dispose() {
super.dispose();
_netSortType.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -117,15 +124,15 @@ class _ServerDetailPageState extends State<ServerDetailPage>
return CustomAppBar(
title: Text(si.spi.name),
actions: [
ShareBtn(
QrShareBtn(
data: si.spi.toJsonString(),
tip: si.spi.name,
tip2: '${libL10n.share} ${l10n.server} ~ ServerBox',
tip2: '${l10n.server} ~ ServerBox',
),
IconButton(
icon: const Icon(Icons.edit),
onPressed: () async {
final delete = await AppRoutes.serverEdit(spi: si.spi).go(context);
final delete = await ServerEditPage.route.go(context, args: si.spi);
if (delete == true) {
context.pop();
}

View File

@@ -13,17 +13,24 @@ import 'package:server_box/data/provider/server.dart';
import 'package:server_box/core/route.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/provider/private_key.dart';
import 'package:server_box/data/store/server.dart';
class ServerEditPage extends StatefulWidget {
const ServerEditPage({super.key, this.spi});
final Spi? args;
final Spi? spi;
const ServerEditPage({super.key, this.args});
static const route = AppRoute<bool, Spi>(
page: ServerEditPage.new,
path: '/server_edit',
);
@override
State<ServerEditPage> createState() => _ServerEditPageState();
}
class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
late final spi = widget.args;
final _nameController = TextEditingController();
final _ipController = TextEditingController();
final _altUrlController = TextEditingController();
@@ -47,6 +54,7 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
late FocusScopeNode _focusScope;
/// -1: non selected, null: password, others: index of private key
final _keyIdx = ValueNotifier<int?>(null);
final _autoConnect = ValueNotifier(true);
final _jumpServer = nvn<String?>();
@@ -64,12 +72,6 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
_portController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_nameFocus.dispose();
_ipFocus.dispose();
_alterUrlFocus.dispose();
_portFocus.dispose();
_usernameFocus.dispose();
_pveAddrCtrl.dispose();
_preferTempDevCtrl.dispose();
_logoUrlCtrl.dispose();
_wolMacCtrl.dispose();
@@ -77,6 +79,21 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
_wolPwdCtrl.dispose();
_netDevCtrl.dispose();
_scriptDirCtrl.dispose();
_nameFocus.dispose();
_ipFocus.dispose();
_alterUrlFocus.dispose();
_portFocus.dispose();
_usernameFocus.dispose();
_pveAddrCtrl.dispose();
_keyIdx.dispose();
_autoConnect.dispose();
_jumpServer.dispose();
_pveIgnoreCert.dispose();
_env.dispose();
_customCmds.dispose();
_tags.dispose();
}
@override
@@ -88,7 +105,7 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
@override
Widget build(BuildContext context) {
final actions = <Widget>[];
if (widget.spi != null) actions.add(_buildDelBtn());
if (spi != null) actions.add(_buildDelBtn());
return GestureDetector(
onTap: () => _focusScope.unfocus(),
@@ -277,7 +294,7 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
onTap: () async {
final res = await KvEditor.route.go(
context,
args: KvEditorArgs(data: widget.spi?.envs ?? {}),
KvEditorArgs(data: spi?.envs ?? {}),
);
if (res == null) return;
_env.value = res;
@@ -409,7 +426,7 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
onTap: () async {
final res = await KvEditor.route.go(
context,
args: KvEditorArgs(data: _customCmds.value),
KvEditorArgs(data: _customCmds.value),
);
if (res == null) return;
_customCmds.value = res;
@@ -477,7 +494,7 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
final srvs = ServerProvider.servers.values
.map((e) => e.value)
.where((e) => e.spi.jumpId == null)
.where((e) => e.spi.id != widget.spi?.id)
.where((e) => e.spi.id != spi?.id)
.toList();
final choice = _jumpServer.listenVal(
(val) {
@@ -602,10 +619,15 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
envs: _env.value.isEmpty ? null : _env.value,
);
if (widget.spi == null) {
if (this.spi == null) {
final existsIds = ServerStore.instance.box.keys;
if (existsIds.contains(spi.id)) {
context.showSnackBar('${l10n.sameIdServerExist}: ${spi.id}');
return;
}
ServerProvider.addServer(spi);
} else {
ServerProvider.updateServer(widget.spi!, spi);
ServerProvider.updateServer(this.spi!, spi);
}
context.pop();
@@ -613,9 +635,8 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
@override
void afterFirstLayout(BuildContext context) {
final spi = widget.spi;
if (spi != null) {
_initWithSpi(spi);
_initWithSpi(spi!);
}
}
@@ -628,7 +649,7 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
_passwordController.text = spi.pwd ?? '';
} else {
_keyIdx.value = PrivateKeyProvider.pkis.value.indexWhere(
(e) => e.id == widget.spi!.keyId,
(e) => e.id == spi.keyId,
);
}
@@ -682,11 +703,11 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
text: libL10n.import,
icon: const Icon(Icons.qr_code, color: Colors.grey),
onTap: () async {
final codes = await BarcodeScannerPage.route.go(
final ret = await BarcodeScannerPage.route.go(
context,
args: const BarcodeScannerPageArgs(),
);
final code = codes?.firstOrNull?.rawValue;
final code = ret?.text;
if (code == null) return;
try {
final spi = Spi.fromJson(json.decode(code));
@@ -705,15 +726,13 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
onPressed: () {
context.showRoundDialog(
title: libL10n.attention,
child: StatefulBuilder(builder: (ctx, setState) {
return Text(libL10n.askContinue(
'${libL10n.delete} ${l10n.server}(${widget.spi!.name})',
));
}),
child: Text(libL10n.askContinue(
'${libL10n.delete} ${l10n.server}(${spi!.name})',
)),
actions: Btn.ok(
onTap: () async {
context.pop();
ServerProvider.delServer(widget.spi!.id);
ServerProvider.delServer(spi!.id);
context.pop(true);
},
red: true,

View File

@@ -8,7 +8,10 @@ import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/extension/ssh_client.dart';
import 'package:server_box/data/model/app/shell_func.dart';
import 'package:server_box/data/model/server/try_limiter.dart';
import 'package:server_box/data/res/build_data.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/view/page/server/edit.dart';
import 'package:server_box/view/page/setting/entry.dart';
import 'package:server_box/view/widget/percent_circle.dart';
import 'package:server_box/core/route.dart';
@@ -18,6 +21,8 @@ import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/provider/server.dart';
import 'package:server_box/view/widget/server_func_btns.dart';
part 'top_bar.dart';
class ServerPage extends StatefulWidget {
const ServerPage({super.key});
@@ -46,6 +51,15 @@ class _ServerPageState extends State<ServerPage>
final _scrollController = ScrollController();
final _autoHideKey = GlobalKey<AutoHideState>();
@override
void dispose() {
super.dispose();
_timer?.cancel();
_scrollController.dispose();
_autoHideKey.currentState?.dispose();
_tag.dispose();
}
@override
void initState() {
super.initState();
@@ -84,7 +98,7 @@ class _ServerPageState extends State<ServerPage>
Widget _buildPortrait() {
return Scaffold(
appBar: TagSwitcher(
appBar: _TopBar(
tags: ServerProvider.tags,
onTagChanged: (p0) => _tag.value = p0,
initTag: _tag.value,
@@ -107,7 +121,7 @@ class _ServerPageState extends State<ServerPage>
controller: _scrollController,
child: FloatingActionButton(
heroTag: 'addServer',
onPressed: () => AppRoutes.serverEdit().go(context),
onPressed: () => ServerEditPage.route.go(context),
tooltip: libL10n.add,
child: const Icon(Icons.add),
),
@@ -129,7 +143,7 @@ class _ServerPageState extends State<ServerPage>
top: 0,
left: 0,
child: IconButton(
onPressed: () => AppRoutes.settings().go(context),
onPressed: () => SettingsPage.route.go(context),
icon: const Icon(Icons.settings, color: Colors.grey),
),
),
@@ -259,7 +273,7 @@ class _ServerPageState extends State<ServerPage>
if (srv.canViewDetails) {
AppRoutes.serverDetail(spi: srv.spi).go(context);
} else {
AppRoutes.serverEdit(spi: srv.spi).go(context);
ServerEditPage.route.go(context, args: srv.spi);
}
},
onLongPress: () {
@@ -270,7 +284,7 @@ class _ServerPageState extends State<ServerPage>
flip: !cardStatus.value.flip,
);
} else {
AppRoutes.serverEdit(spi: srv.spi).go(context);
ServerEditPage.route.go(context, args: srv.spi);
}
},
child: Padding(
@@ -314,15 +328,24 @@ class _ServerPageState extends State<ServerPage>
}
}
final height = _calcCardHeight(srv.conn, cardStatus.value.flip);
return AnimatedContainer(
duration: const Duration(milliseconds: 377),
curve: Curves.fastEaseInToSlowEaseOut,
height: _calcCardHeight(srv.conn, cardStatus.value.flip),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center,
children: children,
height: height,
// Use [OverflowBox] to dismiss the warning of [Column] overflow.
child: OverflowBox(
// If `height == _kCardHeightMin`, the `maxHeight` will be ignored.
//
// You can comment the `maxHeight` then connect&disconnect the server
// to see the difference.
maxHeight: height != _kCardHeightMin ? height : null,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center,
children: children,
),
),
);
},
@@ -384,7 +407,7 @@ class _ServerPageState extends State<ServerPage>
textStyle: textStyle,
),
Btn.column(
onTap: () => AppRoutes.serverEdit(spi: srv.spi).go(context),
onTap: () => ServerEditPage.route.go(context, args: srv.spi),
icon: const Icon(Icons.edit, color: Colors.grey),
text: libL10n.edit,
textStyle: textStyle,
@@ -472,30 +495,18 @@ class _ServerPageState extends State<ServerPage>
null,
),
ServerConn.failed => (
const Icon(
Icons.refresh,
size: 21,
color: Colors.grey,
),
const Icon(Icons.refresh, size: 21, color: Colors.grey),
() {
TryLimiter.reset(s.spi.id);
ServerProvider.refresh(spi: s.spi);
},
),
ServerConn.disconnected => (
const Icon(
MingCute.link_3_line,
size: 19,
color: Colors.grey,
),
const Icon(MingCute.link_3_line, size: 19, color: Colors.grey),
() => ServerProvider.refresh(spi: s.spi)
),
ServerConn.finished => (
const Icon(
MingCute.unlink_2_line,
size: 17,
color: Colors.grey,
),
const Icon(MingCute.unlink_2_line, size: 17, color: Colors.grey),
() => ServerProvider.closeServer(id: s.spi.id),
),
_ when Stores.setting.serverTabUseOldUI.fetch() => (
@@ -507,11 +518,7 @@ class _ServerPageState extends State<ServerPage>
// Or the loading icon will be rescaled.
final wrapped = child is SizedBox
? child
: SizedBox(
height: _kCardHeightMin,
width: 27,
child: child,
);
: SizedBox(height: _kCardHeightMin, width: 27, child: child);
if (onTap == null) return wrapped.paddingOnly(left: 10);
return InkWell(
borderRadius: BorderRadius.circular(7),
@@ -644,7 +651,7 @@ ${ss.err?.message ?? 'null'}
List<String> _filterServers(List<String> order) {
final tag = _tag.value;
if (tag == kDefaultTag) return order;
if (tag == TagSwitcher.kDefaultTag) return order;
return order.where((e) {
final tags = ServerProvider.pick(id: e)?.value.spi.tags;
if (tags == null) return false;

View File

@@ -0,0 +1,62 @@
part of 'tab.dart';
final class _TopBar extends StatelessWidget implements PreferredSizeWidget {
final ValueNotifier<Set<String>> tags;
final void Function(String) onTagChanged;
final String initTag;
const _TopBar({
required this.initTag,
required this.onTagChanged,
required this.tags,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Center(
child: InkWell(
borderRadius: BorderRadius.circular(13),
onTap: () => DebugPage.route.go(
context,
args: const DebugPageArgs(title: 'Logs(${BuildData.build})'),
),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 7),
child: Row(
children: [
Text(
BuildData.name,
style: TextStyle(fontSize: 20),
textAlign: TextAlign.center,
),
Icon(
Icons.keyboard_arrow_right,
color: Colors.grey,
size: 17,
),
],
),
),
),
),
const SizedBox(width: 30),
TagSwitcher(
tags: tags,
onTagChanged: onTagChanged,
initTag: initTag,
singleLine: true,
reversed: true,
).expanded(),
],
),
);
}
@override
Size get preferredSize => const Size.fromHeight(TagSwitcher.kTagBtnHeight);
}

View File

@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:fl_lib/fl_lib.dart';
@@ -6,6 +7,7 @@ import 'package:flutter_highlight/theme_map.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/res/github_id.dart';
import 'package:server_box/data/res/rebuild.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/data/res/url.dart';
@@ -13,70 +15,203 @@ import 'package:server_box/data/res/url.dart';
import 'package:server_box/core/route.dart';
import 'package:server_box/data/model/app/net_view.dart';
import 'package:server_box/data/res/build_data.dart';
import 'package:server_box/view/page/backup.dart';
import 'package:server_box/view/page/editor.dart';
import 'package:server_box/view/page/private_key/list.dart';
const _kIconSize = 23.0;
class SettingPage extends StatefulWidget {
const SettingPage({super.key});
enum SettingsTabs {
app,
privateKey,
backup,
about,
;
@override
State<SettingPage> createState() => _SettingPageState();
String get i18n => switch (this) {
SettingsTabs.app => libL10n.app,
SettingsTabs.privateKey => l10n.privateKey,
SettingsTabs.backup => libL10n.backup,
SettingsTabs.about => libL10n.about,
};
Widget get page => switch (this) {
SettingsTabs.app => const AppSettingsPage(),
SettingsTabs.privateKey => const PrivateKeysListPage(),
SettingsTabs.backup => const BackupPage(),
SettingsTabs.about => const AppAboutPage(),
};
static final List<Widget> pages =
SettingsTabs.values.map((e) => e.page).toList();
}
class _SettingPageState extends State<SettingPage> {
final _setting = Stores.setting;
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
static const route = AppRouteNoArg(page: SettingsPage.new, path: '/settings');
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage>
with SingleTickerProviderStateMixin {
late final _tabCtrl =
TabController(length: SettingsTabs.values.length, vsync: this);
@override
void dispose() {
super.dispose();
_tabCtrl.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: CustomAppBar(
title: Text(libL10n.setting),
actions: [
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => context.showRoundDialog(
title: libL10n.attention,
child: SimpleMarkdown(
data: libL10n.askContinue(
'${libL10n.delete} **${libL10n.all}** ${libL10n.setting}',
),
),
actions: Btn.ok(
onTap: () {
context.pop();
_setting.box.deleteAll(_setting.box.keys);
context.showSnackBar(libL10n.success);
},
red: true,
).toList,
),
),
],
appBar: TabBar(
controller: _tabCtrl,
dividerHeight: 0,
tabAlignment: TabAlignment.center,
isScrollable: true,
tabs: SettingsTabs.values
.map((e) => Tab(text: e.i18n))
.toList(growable: false),
),
body: MultiList(
widthDivider: 2.3,
thumbVisibility: true,
children: [
[const CenterGreyTitle('App'), _buildApp()],
[CenterGreyTitle(l10n.server), _buildServer()],
[
const CenterGreyTitle('SSH'),
_buildSSH(),
const CenterGreyTitle('SFTP'),
_buildSFTP()
],
[
CenterGreyTitle(l10n.container),
_buildContainer(),
CenterGreyTitle(l10n.editor),
_buildEditor(),
],
// actions: [
// IconButton(
// icon: const Icon(Icons.delete),
// onPressed: () => context.showRoundDialog(
// title: libL10n.attention,
// child: SimpleMarkdown(
// data: libL10n.askContinue(
// '${libL10n.delete} **${libL10n.all}** ${libL10n.setting}',
// ),
// ),
// actions: [
// CountDownBtn(
// onTap: () {
// context.pop();
// _setting.box.deleteAll(_setting.box.keys);
// context.showSnackBar(libL10n.success);
// },
// afterColor: Colors.red,
// )
// ],
// ),
// ),
// ],
body: TabBarView(controller: _tabCtrl, children: SettingsTabs.pages),
);
}
}
/// Fullscreen Mode is designed for old mobile phone which can be
/// used as a status screen.
if (isMobile) [CenterGreyTitle(l10n.fullScreen), _buildFullScreen()],
final class AppAboutPage extends StatefulWidget {
const AppAboutPage({super.key});
@override
State<AppAboutPage> createState() => _AppAboutPageState();
}
final class _AppAboutPageState extends State<AppAboutPage>
with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return ListView(
padding: const EdgeInsets.all(13),
children: [
UIs.height13,
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 47, maxWidth: 47),
child: UIs.appIcon,
),
const Text(
'${BuildData.name}\nv${BuildData.build}',
textAlign: TextAlign.center,
style: UIs.text15,
),
UIs.height13,
SizedBox(
height: 47,
child: ListView(
scrollDirection: Axis.horizontal,
children: <Widget>[
Btn.elevated(
icon: const Icon(Icons.edit_document),
text: 'Wiki',
onTap: Urls.appWiki.launch,
),
Btn.elevated(
icon: const Icon(Icons.feedback),
text: libL10n.feedback,
onTap: Urls.appHelp.launch,
),
Btn.elevated(
icon: const Icon(MingCute.question_fill),
text: l10n.license,
onTap: () => showLicensePage(context: context),
),
].joinWith(UIs.width13),
),
),
UIs.height13,
SimpleMarkdown(
data: '''
#### Contributors
${GithubIds.contributors.map((e) => '[$e](${e.url})').join(' ')}
#### Participants
${GithubIds.participants.map((e) => '[$e](${e.url})').join(' ')}
#### My other apps
[GPT Box](https://github.com/lollipopkit/flutter_gpt_box)
${l10n.madeWithLove('[lollipopkit](${Urls.myGithub})')}
''',
).paddingAll(13).cardx,
],
);
}
@override
bool get wantKeepAlive => true;
}
final class AppSettingsPage extends StatefulWidget {
const AppSettingsPage({super.key});
@override
State<AppSettingsPage> createState() => _AppSettingsPageState();
}
final class _AppSettingsPageState extends State<AppSettingsPage> {
final _setting = Stores.setting;
@override
Widget build(BuildContext context) {
return MultiList(
thumbVisibility: true,
children: [
[const CenterGreyTitle('App'), _buildApp()],
[CenterGreyTitle(l10n.server), _buildServer()],
[
const CenterGreyTitle('SSH'),
_buildSSH(),
const CenterGreyTitle('SFTP'),
_buildSFTP()
],
),
[
CenterGreyTitle(l10n.container),
_buildContainer(),
CenterGreyTitle(l10n.editor),
_buildEditor(),
],
/// Fullscreen Mode is designed for old mobile phone which can be
/// used as a status screen.
if (isMobile) [CenterGreyTitle(l10n.fullScreen), _buildFullScreen()],
],
);
}
@@ -94,9 +229,7 @@ class _SettingPageState extends State<SettingPage> {
_buildAppMore(),
];
return Column(
children: children.map((e) => CardX(child: e)).toList(),
);
return Column(children: children.map((e) => e.cardx).toList());
}
Widget _buildFullScreen() {
@@ -1018,6 +1151,7 @@ class _SettingPageState extends State<SettingPage> {
_buildCollapseUI(),
_buildCupertinoRoute(),
if (isDesktop) _buildHideTitleBar(),
_buildEditRawSettings(),
],
);
}
@@ -1182,4 +1316,45 @@ class _SettingPageState extends State<SettingPage> {
},
);
}
Widget _buildEditRawSettings() {
return ListTile(
title: const Text('(Dev) Edit raw json'),
trailing: const Icon(Icons.keyboard_arrow_right),
onTap: _editRawSettings,
);
}
Future<void> _editRawSettings() async {
final map = Stores.setting.box.toJson(includeInternal: false);
final keys = map.keys;
/// Encode [map] to String with indent `\t`
final text = jsonIndentEncoder.convert(map);
final ret = await EditorPage.route.go(
context,
args: EditorPageArgs(
text: text,
langCode: 'json',
title: libL10n.setting,
),
);
final result = ret?.result;
if (result == null) return;
try {
final newSettings = json.decode(result) as Map<String, dynamic>;
Stores.setting.box.putAll(newSettings);
final newKeys = newSettings.keys;
final removedKeys = keys.where((e) => !newKeys.contains(e));
for (final key in removedKeys) {
Stores.setting.box.delete(key);
}
} catch (e, trace) {
context.showRoundDialog(
title: libL10n.error,
child: Text('${l10n.save}:\n$e'),
);
Loggers.app.warning('Update json settings failed', e, trace);
}
}
}

View File

@@ -64,7 +64,7 @@ class _AndroidSettingsPageState extends State<AndroidSettingsPage> {
}
final result = await KvEditor.route.go(
context,
args: KvEditorArgs(data: data, prefix: 'widget_'),
KvEditorArgs(data: data, prefix: 'widget_'),
);
if (result != null) {
_saveWidgetSP(result, data);

Some files were not shown because too many files have changed in this diff Show More