Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
229983d82e | ||
|
|
4928ca600d | ||
|
|
89ec2d94d6 | ||
|
|
393c3e6388 | ||
|
|
dee458e926 | ||
|
|
f89228db40 | ||
|
|
3b6fb6194b | ||
|
|
02444fc2f0 | ||
|
|
aef317a140 | ||
|
|
47aedb2f2e | ||
|
|
eab06abcaf | ||
|
|
c062c12a0e | ||
|
|
d7669c94b8 | ||
|
|
50af289574 | ||
|
|
90b88ed795 | ||
|
|
d611fdcd50 | ||
|
|
db9b2dd818 | ||
|
|
edb49ead67 | ||
|
|
7f0dc656b8 | ||
|
|
b33d0bbc3e | ||
|
|
7d0ea8a58b | ||
|
|
c18732d8f3 | ||
|
|
157af0a354 | ||
|
|
2d9dc044f9 | ||
|
|
479250c207 | ||
|
|
aef7ec911f | ||
|
|
4f9ee7781f | ||
|
|
eb83d05c81 | ||
|
|
329fd33b69 | ||
|
|
931c5f0bf6 | ||
|
|
bcbf1fbc17 | ||
|
|
3e7315dac6 | ||
|
|
4cecfdf7a8 | ||
|
|
0346821cf5 |
2
.github/workflows/release.yml
vendored
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
- 感谢贡献者们!
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -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;
|
||||
|
||||
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-1024.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-1024 1.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
25
lib/app.dart
@@ -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();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,4 +11,8 @@ abstract final class BgRunMC {
|
||||
static void startService() {
|
||||
_channel.invokeMethod('startService');
|
||||
}
|
||||
|
||||
static void stopService() {
|
||||
_channel.invokeMethod('stopService');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import 'package:server_box/data/res/build_data.dart';
|
||||
|
||||
extension BuildDataX on BuildData {
|
||||
static const versionStr = 'v1.0.${BuildData.build}';
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ enum ShellFunc {
|
||||
return '''
|
||||
mkdir -p $scriptDir
|
||||
cat > $scriptPath
|
||||
chmod 744 $scriptPath
|
||||
chmod 755 $scriptPath
|
||||
''';
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
abstract class TagPickable {
|
||||
bool containsTag(String tag);
|
||||
|
||||
String get tagName;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ abstract final class GithubIds {
|
||||
'Liloupar',
|
||||
'dccif',
|
||||
'mikropsoft',
|
||||
'CakesTwix',
|
||||
};
|
||||
|
||||
static const participants = <GhId>{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
49
lib/data/store/no_backup.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
"route": "Routing",
|
||||
"run": "Berlari",
|
||||
"running": "berlari",
|
||||
"sameIdServerExist": "Server dengan ID yang sama sudah ada",
|
||||
"save": "Menyimpan",
|
||||
"saved": "Diselamatkan",
|
||||
"second": "S",
|
||||
|
||||
@@ -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": "仮想キーの自動オフ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 для мониторинга состояния системы. Вы можете проверить содержимое скрипта."
|
||||
}
|
||||
@@ -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
@@ -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 для моніторингу стану системи. Ви можете переглянути вміст скрипта."
|
||||
}
|
||||
@@ -142,6 +142,7 @@
|
||||
"route": "路由",
|
||||
"run": "运行",
|
||||
"running": "运行中",
|
||||
"sameIdServerExist": "已存在相同 id 的服务器",
|
||||
"save": "保存",
|
||||
"saved": "已保存",
|
||||
"second": "秒",
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
"route": "路由",
|
||||
"run": "運行",
|
||||
"running": "運作中",
|
||||
"sameIdServerExist": "已存在相同 ID 的伺服器",
|
||||
"save": "保存",
|
||||
"saved": "已保存",
|
||||
"second": "秒",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ class _ContainerPageState extends State<ContainerPage> {
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_textController.dispose();
|
||||
_container.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
)
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>[
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
62
lib/view/page/server/top_bar.dart
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||