Compare commits

..

11 Commits

Author SHA1 Message Date
lollipopkit🏳️‍⚧️
b1b0d9a18f bump: v1231 2025-09-01 01:19:23 +08:00
lollipopkit🏳️‍⚧️
56e67f4725 fix: sync will refresh the entire app (#877) 2025-09-01 01:18:06 +08:00
lollipopkit🏳️‍⚧️
3b7fdf36fb opt. 2025-08-31 23:59:53 +08:00
lollipopkit🏳️‍⚧️
5291d316a2 fix: ensure unique IDs for bulk server import to prevent overwriting (#875) 2025-08-31 21:20:27 +08:00
lollipopkit🏳️‍⚧️
4c369546da fix: replace String.fromCharCodes with utf8.decode for proper Chinese character handling in JSON import (#874) 2025-08-31 20:06:47 +08:00
lollipopkit🏳️‍⚧️
12a243d139 feat: import servers from ~/.ssh/config (#873) 2025-08-31 19:33:29 +08:00
lollipopkit🏳️‍⚧️
a97b3cf43e opt.: bak pwd is optional (#872) 2025-08-31 11:11:47 +08:00
lollipopkit🏳️‍⚧️
53a7c0d8ff migrate: riverpod + freezed (#870) 2025-08-31 00:55:54 +08:00
lollipopkit🏳️‍⚧️
9cb705f8dd fix: parsing hostname (#865) 2025-08-22 09:18:21 +08:00
lollipopkit🏳️‍⚧️
8270674b7d chore: tests 2025-08-22 00:25:26 +08:00
lxdklp
24fd4b782d fix: GBK decoding fallback (#863)
* fix #757

* fix #757

* apply the code recommendations from sourcery ai

* Make sure raw is non-empty data

* Modified the way to judge gbk, fixed the problem that null cannot throw an error
2025-08-21 23:28:06 +08:00
121 changed files with 8765 additions and 1920 deletions

3
devtools_options.yaml Normal file
View File

@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@@ -748,7 +748,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1220;
CURRENT_PROJECT_VERSION = 1231;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
@@ -758,7 +758,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1220;
MARKETING_VERSION = 1.0.1231;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -884,7 +884,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1220;
CURRENT_PROJECT_VERSION = 1231;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
@@ -894,7 +894,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1220;
MARKETING_VERSION = 1.0.1231;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -912,7 +912,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1220;
CURRENT_PROJECT_VERSION = 1231;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
@@ -922,7 +922,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1220;
MARKETING_VERSION = 1.0.1231;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -943,7 +943,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1220;
CURRENT_PROJECT_VERSION = 1231;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -956,7 +956,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1220;
MARKETING_VERSION = 1.0.1231;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
@@ -982,7 +982,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1220;
CURRENT_PROJECT_VERSION = 1231;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -995,7 +995,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1220;
MARKETING_VERSION = 1.0.1231;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1018,7 +1018,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1220;
CURRENT_PROJECT_VERSION = 1231;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -1031,7 +1031,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1220;
MARKETING_VERSION = 1.0.1231;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1054,7 +1054,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1220;
CURRENT_PROJECT_VERSION = 1231;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1066,7 +1066,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1220;
MARKETING_VERSION = 1.0.1231;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
@@ -1095,7 +1095,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1220;
CURRENT_PROJECT_VERSION = 1231;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1107,7 +1107,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1220;
MARKETING_VERSION = 1.0.1231;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
PRODUCT_NAME = ServerBox;
@@ -1133,7 +1133,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1220;
CURRENT_PROJECT_VERSION = 1231;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1145,7 +1145,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1220;
MARKETING_VERSION = 1.0.1231;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
PRODUCT_NAME = ServerBox;

View File

@@ -2,10 +2,10 @@ import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:server_box/data/model/app/scripts/cmd_types.dart';
import 'package:server_box/data/model/server/dist.dart';
import 'package:server_box/data/model/server/server.dart';
import 'package:server_box/data/provider/server/single.dart';
import 'package:server_box/data/res/store.dart';
extension LogoExt on Server {
extension LogoExt on ServerState {
String? getLogoUrl(BuildContext context) {
var logoUrl = spi.custom?.logoUrl ?? Stores.setting.serverLogoUrl.fetch().selfNotEmptyOrNull;
if (logoUrl == null) {

View File

@@ -14,11 +14,7 @@ final class BakSyncer extends SyncIface {
@override
Future<void> saveToFile() async {
final pwd = await SecureStoreProps.bakPwd.read();
if (pwd == null || pwd.isEmpty) {
// Enforce password for non-clipboard backups
throw Exception('Backup password not set');
}
await BackupV2.backup(null, pwd);
await BackupV2.backup(null, pwd?.isEmpty == true ? null : pwd);
}
@override

View File

@@ -78,7 +78,7 @@ Future<SSHClient> genClient(
Loggers.app.warning('genClient', e);
if (spi.alterUrl == null) rethrow;
try {
final res = spi.fromStringUrl();
final res = spi.parseAlterUrl();
alterUser = res.$2;
return await SSHSocket.connect(res.$1, res.$3, timeout: timeout);
} catch (e) {

View File

@@ -0,0 +1,84 @@
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/store/server.dart';
class ServerDeduplication {
/// Remove duplicate servers from the import list based on existing servers
/// Returns the deduplicated list
static List<Spi> deduplicateServers(List<Spi> importedServers) {
final existingServers = ServerStore.instance.fetch();
final deduplicated = <Spi>[];
for (final imported in importedServers) {
if (!_isDuplicate(imported, existingServers)) {
deduplicated.add(imported);
}
}
return deduplicated;
}
/// Check if an imported server is a duplicate of an existing server
static bool _isDuplicate(Spi imported, List<Spi> existing) {
for (final existingSpi in existing) {
if (imported.isSameAs(existingSpi)) {
return true;
}
}
return false;
}
/// Resolve name conflicts by appending suffixes
static List<Spi> resolveNameConflicts(List<Spi> importedServers) {
final existingServers = ServerStore.instance.fetch();
final existingNames = existingServers.map((s) => s.name).toSet();
final processedNames = <String>{};
final result = <Spi>[];
for (final server in importedServers) {
String newName = server.name;
int suffix = 1;
// Check against both existing servers and already processed servers
while (existingNames.contains(newName) || processedNames.contains(newName)) {
newName = '${server.name} ($suffix)';
suffix++;
}
processedNames.add(newName);
if (newName != server.name) {
result.add(server.copyWith(name: newName));
} else {
result.add(server);
}
}
return result;
}
/// Get summary of import operation
static ImportSummary getImportSummary(List<Spi> originalList, List<Spi> deduplicatedList) {
final duplicateCount = originalList.length - deduplicatedList.length;
return ImportSummary(
total: originalList.length,
duplicates: duplicateCount,
toImport: deduplicatedList.length,
);
}
}
class ImportSummary {
final int total;
final int duplicates;
final int toImport;
const ImportSummary({
required this.total,
required this.duplicates,
required this.toImport,
});
bool get hasDuplicates => duplicates > 0;
bool get hasItemsToImport => toImport > 0;
}

View File

@@ -0,0 +1,187 @@
import 'dart:io';
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
/// Utility class to parse SSH config files under `~/.ssh/config`
abstract final class SSHConfig {
static const String _defaultPath = '~/.ssh/config';
static String? get _homePath {
final homePath = isWindows ? Platform.environment['USERPROFILE'] : Platform.environment['HOME'];
if (homePath == null || homePath.isEmpty) {
return null;
}
return homePath;
}
/// Get possible SSH config file paths, with macOS-specific handling
static List<String> get _possibleConfigPaths {
final paths = <String>[];
final homePath = _homePath;
if (homePath != null) {
// Standard path
paths.add('$homePath/.ssh/config');
// On macOS, also try the actual user home directory
if (isMacOS) {
// Try to get the real user home directory
final username = Platform.environment['USER'];
if (username != null) {
paths.add('/Users/$username/.ssh/config');
}
}
}
return paths;
}
/// Parse SSH config file and return a list of Spi objects
static Future<List<Spi>> parseConfig([String? configPath]) async {
final (file, exists) = configExists(configPath);
if (!exists || file == null) {
Loggers.app.info('SSH config file does not exist at path: ${configPath ?? _defaultPath}');
return [];
}
final content = await file.readAsString();
return _parseSSHConfig(content);
}
/// Parse SSH config content
static List<Spi> _parseSSHConfig(String content) {
final servers = <Spi>[];
final lines = content.split('\n');
String? currentHost;
String? hostname;
String? user;
int port = 22;
String? identityFile;
String? jumpHost;
void addServer() {
if (currentHost != null && currentHost != '*' && hostname != null) {
final spi = Spi(
name: currentHost,
ip: hostname,
port: port,
user: user ?? 'root', // Default user is 'root'
keyId: identityFile,
jumpId: jumpHost,
);
servers.add(spi);
}
}
for (final line in lines) {
final trimmed = line.trim();
if (trimmed.isEmpty || trimmed.startsWith('#')) continue;
// Handle inline comments
final commentIndex = trimmed.indexOf('#');
final cleanLine = commentIndex != -1 ? trimmed.substring(0, commentIndex).trim() : trimmed;
if (cleanLine.isEmpty) continue;
final parts = cleanLine.split(RegExp(r'\s+'));
if (parts.length < 2) continue;
final key = parts[0].toLowerCase();
var value = parts.sublist(1).join(' ');
// Remove quotes from values
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.substring(1, value.length - 1);
}
switch (key) {
case 'host':
// Save previous host config
addServer();
// Reset for new host
final originalValue = parts.sublist(1).join(' ');
final isQuoted =
(originalValue.startsWith('"') && originalValue.endsWith('"')) ||
(originalValue.startsWith("'") && originalValue.endsWith("'"));
currentHost = value;
// Skip hosts with multiple patterns (contains spaces but not quoted)
if (currentHost.contains(' ') && !isQuoted) {
currentHost = null; // Mark as invalid to skip
}
hostname = null;
user = null;
port = 22;
identityFile = null;
jumpHost = null;
break;
case 'hostname':
hostname = value;
break;
case 'user':
user = value;
break;
case 'port':
port = int.tryParse(value) ?? 22;
break;
case 'identityfile':
identityFile = value; // Store the path directly
break;
case 'proxyjump':
case 'proxycommand':
jumpHost = _extractJumpHost(value);
break;
}
}
// Add the last server
addServer();
return servers;
}
/// Extract jump host from ProxyJump or ProxyCommand
static String? _extractJumpHost(String value) {
// For ProxyJump, the format is usually: user@host:port
// For ProxyCommand, it's more complex and might need custom parsing
if (value.contains('@')) {
return value.split(' ').first;
}
return null;
}
/// Check if SSH config file exists, trying multiple possible paths
static (File?, bool) configExists([String? configPath]) {
if (configPath != null) {
// If specific path is provided, use it directly
final homePath = _homePath;
if (homePath == null) {
Loggers.app.warning('Cannot determine home directory for SSH config parsing.');
return (null, false);
}
final expandedPath = configPath.replaceFirst('~', homePath);
dprint('Checking SSH config at path: $expandedPath');
final file = File(expandedPath);
return (file, file.existsSync());
}
// Try multiple possible paths
for (final path in _possibleConfigPaths) {
dprint('Checking SSH config at path: $path');
final file = File(path);
if (file.existsSync()) {
dprint('Found SSH config at: $path');
return (file, true);
}
}
dprint('SSH config file not found in any of the expected locations');
return (null, false);
}
}

View File

@@ -4,6 +4,9 @@ import 'dart:io';
import 'package:fl_lib/fl_lib.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:logging/logging.dart';
import 'package:server_box/data/provider/private_key.dart';
import 'package:server_box/data/provider/server/all.dart';
import 'package:server_box/data/provider/snippet.dart';
import 'package:server_box/data/res/misc.dart';
import 'package:server_box/data/res/store.dart';
@@ -44,17 +47,17 @@ abstract class BackupV2 with _$BackupV2 implements Mergeable {
Future<void> merge({bool force = false}) async {
_loggerV2.info('Merging...');
// Merge each store
await Mergeable.mergeStore(backupData: spis, store: Stores.server, force: force);
await Mergeable.mergeStore(backupData: snippets, store: Stores.snippet, force: force);
await Mergeable.mergeStore(backupData: keys, store: Stores.key, force: force);
// Merge each store and check if changes were made
final serverChanged = await Mergeable.mergeStore(backupData: spis, store: Stores.server, force: force);
final snippetChanged = await Mergeable.mergeStore(backupData: snippets, store: Stores.snippet, force: force);
final keyChanged = await Mergeable.mergeStore(backupData: keys, store: Stores.key, force: force);
await Mergeable.mergeStore(backupData: container, store: Stores.container, force: force);
await Mergeable.mergeStore(backupData: history, store: Stores.history, force: force);
await Mergeable.mergeStore(backupData: settings, store: Stores.setting, force: force);
// Reload providers and notify listeners
Provider.reload();
RNodes.app.notify();
if (serverChanged) GlobalRef.gRef?.read(serversNotifierProvider.notifier).reload();
if (snippetChanged) GlobalRef.gRef?.read(snippetNotifierProvider.notifier).reload();
if (keyChanged) GlobalRef.gRef?.read(privateKeyNotifierProvider.notifier).reload();
_loggerV2.info('Merge completed');
}

View File

@@ -11,27 +11,10 @@ class BackupService {
/// Perform backup operation with the given source
static Future<void> backup(BuildContext context, BackupSource source) async {
try {
String? password;
final saved = await SecureStoreProps.bakPwd.read();
final password = saved?.isEmpty == true ? null : saved;
if (source is ClipboardBackupSource) {
// Clipboard backup: allow optional password
password = await _getClipboardPassword(context);
if (password == null) return; // canceled
} else {
// All other backups require pre-set bakPwd (SecureStore)
final saved = await SecureStoreProps.bakPwd.read();
if (saved == null || saved.isEmpty) {
// Prompt to set before proceeding
password = await _showPasswordDialog(context, hint: l10n.backupPasswordTip);
if (password == null || password.isEmpty) return; // Not set
await SecureStoreProps.bakPwd.write(password);
context.showSnackBar(l10n.backupPasswordSet);
} else {
password = saved;
}
}
final path = await BackupV2.backup(null, password.isEmpty ? null : password);
final path = await BackupV2.backup(null, password?.isEmpty == true ? null : password);
await source.saveContent(path);
if (source is ClipboardBackupSource) {
@@ -56,34 +39,6 @@ class BackupService {
await restoreFromText(context, text);
}
/// Handle password dialog for backup operations
static Future<String?> _getClipboardPassword(BuildContext context) async {
// Use saved bakPwd as default for clipboard flow if exists, but allow empty/custom
final savedPassword = await SecureStoreProps.bakPwd.read();
String? password;
if (savedPassword != null && savedPassword.isNotEmpty) {
final useCustom = await context.showRoundDialog<bool>(
title: l10n.backupPassword,
child: Text(l10n.backupPasswordTip),
actions: [
Btn.cancel(),
TextButton(onPressed: () => context.pop(false), child: Text(l10n.backupPasswordSet)),
TextButton(onPressed: () => context.pop(true), child: Text(libL10n.custom)),
],
);
if (useCustom == null) return null;
if (useCustom) {
password = await _showPasswordDialog(context, initial: savedPassword);
} else {
password = savedPassword;
}
} else {
password = await _showPasswordDialog(context);
}
return password;
}
/// Handle restore from text with decryption support
static Future<void> restoreFromText(BuildContext context, String text) async {
// Check if backup is encrypted

View File

@@ -4,7 +4,7 @@ import 'package:server_box/core/extension/context/locale.dart';
enum SSHErrType { unknown, connect, auth, noPrivateKey, chdir, segements, writeScript, getStatus }
class SSHErr extends Err<SSHErrType> {
SSHErr({required super.type, super.message});
const SSHErr({required super.type, super.message});
@override
String? get solution => switch (type) {
@@ -29,7 +29,7 @@ enum ContainerErrType {
}
class ContainerErr extends Err<ContainerErrType> {
ContainerErr({required super.type, super.message});
const ContainerErr({required super.type, super.message});
@override
String? get solution => null;
@@ -38,7 +38,7 @@ class ContainerErr extends Err<ContainerErrType> {
enum ICloudErrType { generic, notFound, multipleFiles }
class ICloudErr extends Err<ICloudErrType> {
ICloudErr({required super.type, super.message});
const ICloudErr({required super.type, super.message});
@override
String? get solution => null;
@@ -47,7 +47,7 @@ class ICloudErr extends Err<ICloudErrType> {
enum WebdavErrType { generic, notFound }
class WebdavErr extends Err<WebdavErrType> {
WebdavErr({required super.type, super.message});
const WebdavErr({required super.type, super.message});
@override
String? get solution => null;
@@ -56,7 +56,7 @@ class WebdavErr extends Err<WebdavErrType> {
enum PveErrType { unknown, net, loginFailed }
class PveErr extends Err<PveErrType> {
PveErr({required super.type, super.message});
const PveErr({required super.type, super.message});
@override
String? get solution => null;

View File

@@ -1,7 +1,6 @@
import 'package:server_box/data/model/app/scripts/script_builders.dart';
import 'package:server_box/data/model/app/scripts/script_consts.dart';
import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/provider/server.dart';
/// Shell functions available in the ServerBox application
enum ShellFunc {
@@ -26,8 +25,8 @@ enum ShellFunc {
};
/// Execute this shell function on the specified server
String exec(String id, {SystemType? systemType}) {
final scriptPath = ShellFuncManager.getScriptPath(id, systemType: systemType);
String exec(String id, {SystemType? systemType, required String? customDir}) {
final scriptPath = ShellFuncManager.getScriptPath(id, systemType: systemType, customDir: customDir);
final isWindows = systemType == SystemType.windows;
final builder = ScriptBuilderFactory.getBuilder(isWindows);
@@ -51,11 +50,10 @@ class ShellFuncManager {
/// Get the script directory for the given [id].
///
/// Checks for custom script directory first, then falls back to default.
static String getScriptDir(String id, {SystemType? systemType}) {
final customScriptDir = ServerProvider.pick(id: id)?.value.spi.custom?.scriptDir;
static String getScriptDir(String id, {SystemType? systemType, required String? customDir}) {
final isWindows = systemType == SystemType.windows;
if (customScriptDir != null) return _normalizeDir(customScriptDir, isWindows);
if (customDir != null) return _normalizeDir(customDir, isWindows);
return ScriptPaths.getScriptDir(id, isWindows: isWindows);
}
@@ -66,11 +64,10 @@ class ShellFuncManager {
}
/// Get the full script path for the given [id]
static String getScriptPath(String id, {SystemType? systemType}) {
final customScriptDir = ServerProvider.pick(id: id)?.value.spi.custom?.scriptDir;
if (customScriptDir != null) {
static String getScriptPath(String id, {SystemType? systemType, required String? customDir}) {
if (customDir != null) {
final isWindows = systemType == SystemType.windows;
final normalizedDir = _normalizeDir(customScriptDir, isWindows);
final normalizedDir = _normalizeDir(customDir, isWindows);
final fileName = isWindows ? ScriptConstants.scriptFileWindows : ScriptConstants.scriptFile;
final separator = isWindows ? ScriptConstants.windowsPathSeparator : ScriptConstants.unixPathSeparator;
return '$normalizedDir$separator$fileName';
@@ -81,8 +78,8 @@ class ShellFuncManager {
}
/// Get the installation shell command for the script
static String getInstallShellCmd(String id, {SystemType? systemType}) {
final scriptDir = getScriptDir(id, systemType: systemType);
static String getInstallShellCmd(String id, {SystemType? systemType, required String? customDir}) {
final scriptDir = getScriptDir(id, systemType: systemType, customDir: customDir);
final isWindows = systemType == SystemType.windows;
final normalizedDir = _normalizeDir(scriptDir, isWindows);
final builder = ScriptBuilderFactory.getBuilder(isWindows);

View File

@@ -20,11 +20,11 @@ ServerCustom _$ServerCustomFromJson(Map<String, dynamic> json) => ServerCustom(
Map<String, dynamic> _$ServerCustomToJson(ServerCustom instance) =>
<String, dynamic>{
'pveAddr': ?instance.pveAddr,
if (instance.pveAddr case final value?) 'pveAddr': value,
'pveIgnoreCert': instance.pveIgnoreCert,
'cmds': ?instance.cmds,
'preferTempDev': ?instance.preferTempDev,
'logoUrl': ?instance.logoUrl,
'netDev': ?instance.netDev,
'scriptDir': ?instance.scriptDir,
if (instance.cmds case final value?) 'cmds': value,
if (instance.preferTempDev case final value?) 'preferTempDev': value,
if (instance.logoUrl case final value?) 'logoUrl': value,
if (instance.netDev case final value?) 'netDev': value,
if (instance.scriptDir case final value?) 'scriptDir': value,
};

View File

@@ -1,4 +1,3 @@
import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/model/app/scripts/cmd_types.dart';
import 'package:server_box/data/model/server/amd.dart';
@@ -11,25 +10,9 @@ import 'package:server_box/data/model/server/memory.dart';
import 'package:server_box/data/model/server/net_speed.dart';
import 'package:server_box/data/model/server/nvdia.dart';
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/model/server/temp.dart';
class Server {
Spi spi;
ServerStatus status;
SSHClient? client;
ServerConn conn;
Server(this.spi, this.status, this.conn, {this.client});
bool get needGenClient => conn < ServerConn.connecting;
bool get canViewDetails => conn == ServerConn.finished;
String get id => spi.id;
}
class ServerStatus {
Cpus cpu;
Memory mem;

View File

@@ -4,10 +4,8 @@ import 'package:fl_lib/fl_lib.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/server/custom.dart';
import 'package:server_box/data/model/server/server.dart';
import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/model/server/wol_cfg.dart';
import 'package:server_box/data/provider/server.dart';
import 'package:server_box/data/store/server.dart';
part 'server_private_info.freezed.dart';
@@ -58,6 +56,7 @@ abstract class Spi with _$Spi {
@override
String toString() => 'Spi<$oldId>';
/// Parse the [id], if it's null or empty, generate a new one.
static String parseId(Object? id) {
if (id == null || id is! String || id.isEmpty) return ShortId.generate();
return id;
@@ -85,23 +84,26 @@ extension Spix on Spi {
return newSpi.id;
}
/// Json encode to string.
String toJsonString() => json.encode(toJson());
VNode<Server>? get server => ServerProvider.pick(spi: this);
VNode<Server>? get jumpServer => ServerProvider.pick(id: jumpId);
bool shouldReconnect(Spi old) {
return user != old.user ||
ip != old.ip ||
port != old.port ||
pwd != old.pwd ||
keyId != old.keyId ||
alterUrl != old.alterUrl ||
jumpId != old.jumpId ||
custom?.cmds != old.custom?.cmds;
/// Returns true if the connection info is the same as [other].
bool isSameAs(Spi other) {
return user == other.user &&
ip == other.ip &&
port == other.port &&
pwd == other.pwd &&
keyId == other.keyId &&
jumpId == other.jumpId;
}
(String ip, String usr, int port) fromStringUrl() {
/// Returns true if the connection should be re-established.
bool shouldReconnect(Spi old) {
return !isSameAs(old) || alterUrl != old.alterUrl || custom?.cmds != old.custom?.cmds;
}
/// Parse the [alterUrl] to (ip, user, port).
(String ip, String usr, int port) parseAlterUrl() {
if (alterUrl == null) {
throw SSHErr(type: SSHErrType.connect, message: 'alterUrl is null');
}
@@ -146,5 +148,6 @@ extension Spix on Spi {
id: 'id',
);
/// Returns true if the user is 'root'.
bool get isRoot => user == 'root';
}

View File

@@ -41,18 +41,19 @@ Map<String, dynamic> _$SpiToJson(_Spi instance) => <String, dynamic>{
'ip': instance.ip,
'port': instance.port,
'user': instance.user,
'pwd': ?instance.pwd,
'pubKeyId': ?instance.keyId,
'tags': ?instance.tags,
'alterUrl': ?instance.alterUrl,
if (instance.pwd case final value?) 'pwd': value,
if (instance.keyId case final value?) 'pubKeyId': value,
if (instance.tags case final value?) 'tags': value,
if (instance.alterUrl case final value?) 'alterUrl': value,
'autoConnect': instance.autoConnect,
'jumpId': ?instance.jumpId,
'custom': ?instance.custom,
'wolCfg': ?instance.wolCfg,
'envs': ?instance.envs,
if (instance.jumpId case final value?) 'jumpId': value,
if (instance.custom case final value?) 'custom': value,
if (instance.wolCfg case final value?) 'wolCfg': value,
if (instance.envs case final value?) 'envs': value,
'id': instance.id,
'customSystemType': ?_$SystemTypeEnumMap[instance.customSystemType],
'disabledCmdTypes': ?instance.disabledCmdTypes,
if (_$SystemTypeEnumMap[instance.customSystemType] case final value?)
'customSystemType': value,
if (instance.disabledCmdTypes case final value?) 'disabledCmdTypes': value,
};
const _$SystemTypeEnumMap = {

View File

@@ -104,6 +104,11 @@ Future<ServerStatus> _getLinuxStatus(ServerStatusUpdateReq req) async {
try {
req.ss.disk = Disk.parse(StatusCmdType.disk.findInMap(parsedOutput));
} catch (e, s) {
Loggers.app.warning(e, s);
}
try {
req.ss.diskUsage = DiskUsage.parse(req.ss.disk);
} catch (e, s) {
Loggers.app.warning(e, s);
@@ -294,7 +299,9 @@ String? _parseSysVer(String raw) {
String? _parseHostName(String raw) {
if (raw.isEmpty) return null;
if (raw.contains(ScriptConstants.scriptFile)) return null;
return raw;
final trimmed = raw.trim();
if (trimmed.isEmpty) return null;
return trimmed;
}
// Windows status parsing implementation

View File

@@ -16,5 +16,5 @@ Map<String, dynamic> _$WakeOnLanCfgToJson(WakeOnLanCfg instance) =>
<String, dynamic>{
'mac': instance.mac,
'ip': instance.ip,
'pwd': ?instance.pwd,
if (instance.pwd case final value?) 'pwd': value,
};

View File

@@ -6,57 +6,20 @@ part of 'app.dart';
// RiverpodGenerator
// **************************************************************************
@ProviderFor(AppStates)
const appStatesProvider = AppStatesProvider._();
final class AppStatesProvider extends $NotifierProvider<AppStates, AppState> {
const AppStatesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'appStatesProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$appStatesHash();
@$internal
@override
AppStates create() => AppStates();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AppState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AppState>(value),
);
}
}
String _$appStatesHash() => r'ef96f10f6fff0f3dd6d3128ebf070ad79cbc8bc9';
abstract class _$AppStates extends $Notifier<AppState> {
AppState build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AppState, AppState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AppState, AppState>,
AppState,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// See also [AppStates].
@ProviderFor(AppStates)
final appStatesProvider = NotifierProvider<AppStates, AppState>.internal(
AppStates.new,
name: r'appStatesProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$appStatesHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$AppStates = Notifier<AppState>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -4,6 +4,8 @@ import 'dart:convert';
import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:server_box/core/extension/ssh_client.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/app/scripts/script_consts.dart';
@@ -12,63 +14,66 @@ import 'package:server_box/data/model/container/ps.dart';
import 'package:server_box/data/model/container/type.dart';
import 'package:server_box/data/res/store.dart';
part 'container.freezed.dart';
part 'container.g.dart';
final _dockerNotFound = RegExp(r"command not found|Unknown command|Command '\w+' not found");
class ContainerProvider extends ChangeNotifier {
final SSHClient? client;
final String userName;
final String hostId;
final BuildContext context;
List<ContainerPs>? items;
List<ContainerImg>? images;
String? version;
ContainerErr? error;
String? runLog;
ContainerType type;
var sudoCompleter = Completer<bool>();
bool isBusy = false;
@freezed
abstract class ContainerState with _$ContainerState {
const factory ContainerState({
@Default(null) List<ContainerPs>? items,
@Default(null) List<ContainerImg>? images,
@Default(null) String? version,
@Default(null) ContainerErr? error,
@Default(null) String? runLog,
@Default(ContainerType.docker) ContainerType type,
@Default(false) bool isBusy,
}) = _ContainerState;
}
ContainerProvider({
required this.client,
required this.userName,
required this.hostId,
required this.context,
}) : type = Stores.container.getType(hostId) {
refresh();
@riverpod
class ContainerNotifier extends _$ContainerNotifier {
var sudoCompleter = Completer<bool>();
@override
ContainerState build(SSHClient? client, String userName, String hostId, BuildContext context) {
this.client = client;
this.userName = userName;
this.hostId = hostId;
this.context = context;
final type = Stores.container.getType(hostId);
final initialState = ContainerState(type: type);
// Async initialization
Future.microtask(() => refresh());
return initialState;
}
Future<void> setType(ContainerType type) async {
this.type = type;
state = state.copyWith(
type: type,
error: null,
runLog: null,
items: null,
images: null,
version: null,
);
Stores.container.setType(type, hostId);
error = runLog = items = images = version = null;
sudoCompleter = Completer<bool>();
notifyListeners();
await refresh();
}
// Future<bool> _checkDockerInstalled(SSHClient client) async {
// final session = await client.execute("docker");
// await session.done;
// // debugPrint('docker code: ${session.exitCode}');
// return session.exitCode == 0;
// }
// String _removeSudoPrompts(String value) {
// final regex = RegExp(r"\[sudo\] password for \w+:");
// if (value.startsWith(regex)) {
// return value.replaceFirstMapped(regex, (match) => "");
// }
// return value;
// }
void _requiresSudo() async {
/// Podman is rootless
if (type == ContainerType.podman) return sudoCompleter.complete(false);
if (state.type == ContainerType.podman) return sudoCompleter.complete(false);
if (!Stores.setting.containerTrySudo.fetch()) {
return sudoCompleter.complete(false);
}
final res = await client?.run(_wrap(ContainerCmdType.images.exec(type)));
final res = await client?.run(_wrap(ContainerCmdType.images.exec(state.type)));
if (res?.string.toLowerCase().contains('permission denied') ?? false) {
return sudoCompleter.complete(true);
}
@@ -76,8 +81,8 @@ class ContainerProvider extends ChangeNotifier {
}
Future<void> refresh({bool isAuto = false}) async {
if (isBusy) return;
isBusy = true;
if (state.isBusy) return;
state = state.copyWith(isBusy: true);
if (!sudoCompleter.isCompleted) _requiresSudo();
@@ -85,11 +90,14 @@ class ContainerProvider extends ChangeNotifier {
/// If sudo is required and auto refresh is enabled, skip the refresh.
/// Or this will ask for pwd again and again.
if (sudo && isAuto) return;
if (sudo && isAuto) {
state = state.copyWith(isBusy: false);
return;
}
final includeStats = Stores.setting.containerParseStat.fetch();
var raw = '';
final cmd = _wrap(ContainerCmdType.execAll(type, sudo: sudo, includeStats: includeStats));
final cmd = _wrap(ContainerCmdType.execAll(state.type, sudo: sudo, includeStats: includeStats));
final code = await client?.execWithPwd(
cmd,
context: context,
@@ -97,75 +105,79 @@ class ContainerProvider extends ChangeNotifier {
id: hostId,
);
isBusy = false;
state = state.copyWith(isBusy: false);
if (!context.mounted) return;
/// Code 127 means command not found
if (code == 127 || raw.contains(_dockerNotFound)) {
error = ContainerErr(type: ContainerErrType.notInstalled);
notifyListeners();
state = state.copyWith(error: ContainerErr(type: ContainerErrType.notInstalled));
return;
}
// Check result segments count
final segments = raw.split(ScriptConstants.separator);
if (segments.length != ContainerCmdType.values.length) {
error = ContainerErr(
type: ContainerErrType.segmentsNotMatch,
message: 'Container segments: ${segments.length}',
state = state.copyWith(
error: ContainerErr(
type: ContainerErrType.segmentsNotMatch,
message: 'Container segments: ${segments.length}',
),
);
Loggers.app.warning('Container segments: ${segments.length}\n$raw');
notifyListeners();
return;
}
// Parse version
final verRaw = ContainerCmdType.version.find(segments);
try {
version = json.decode(verRaw)['Client']['Version'];
final version = json.decode(verRaw)['Client']['Version'];
state = state.copyWith(version: version, error: null);
} catch (e, trace) {
error = ContainerErr(type: ContainerErrType.invalidVersion, message: '$e');
state = state.copyWith(
error: ContainerErr(type: ContainerErrType.invalidVersion, message: '$e'),
);
Loggers.app.warning('Container version failed', e, trace);
} finally {
notifyListeners();
}
// Parse ps
final psRaw = ContainerCmdType.ps.find(segments);
try {
final lines = psRaw.split('\n');
if (type == ContainerType.docker) {
if (state.type == ContainerType.docker) {
/// Due to the fetched data is not in json format, skip table header
lines.removeWhere((element) => element.contains('CONTAINER ID'));
}
lines.removeWhere((element) => element.isEmpty);
items = lines.map((e) => ContainerPs.fromRaw(e, type)).toList();
final items = lines.map((e) => ContainerPs.fromRaw(e, state.type)).toList();
state = state.copyWith(items: items);
} catch (e, trace) {
error = ContainerErr(type: ContainerErrType.parsePs, message: '$e');
state = state.copyWith(
error: ContainerErr(type: ContainerErrType.parsePs, message: '$e'),
);
Loggers.app.warning('Container ps failed', e, trace);
} finally {
notifyListeners();
}
// Parse images
final imageRaw = ContainerCmdType.images.find(segments).trim();
final isEntireJson = imageRaw.startsWith('[') && imageRaw.endsWith(']');
try {
List<ContainerImg> images;
if (isEntireJson) {
images = (json.decode(imageRaw) as List)
.map((e) => ContainerImg.fromRawJson(json.encode(e), type))
.map((e) => ContainerImg.fromRawJson(json.encode(e), state.type))
.toList();
} else {
final lines = imageRaw.split('\n');
lines.removeWhere((element) => element.isEmpty);
images = lines.map((e) => ContainerImg.fromRawJson(e, type)).toList();
images = lines.map((e) => ContainerImg.fromRawJson(e, state.type)).toList();
}
state = state.copyWith(images: images);
} catch (e, trace) {
error = ContainerErr(type: ContainerErrType.parseImages, message: '$e');
state = state.copyWith(
error: ContainerErr(type: ContainerErrType.parseImages, message: '$e'),
);
Loggers.app.warning('Container images failed', e, trace);
} finally {
notifyListeners();
}
// Parse stats
@@ -173,7 +185,7 @@ class ContainerProvider extends ChangeNotifier {
try {
final statsLines = statsRaw.split('\n');
statsLines.removeWhere((element) => element.isEmpty);
for (var item in items!) {
for (var item in state.items!) {
final id = item.id;
if (id == null) continue;
final statsLine = statsLines.firstWhereOrNull(
@@ -185,10 +197,10 @@ class ContainerProvider extends ChangeNotifier {
item.parseStats(statsLine);
}
} catch (e, trace) {
error = ContainerErr(type: ContainerErrType.parseStats, message: '$e');
state = state.copyWith(
error: ContainerErr(type: ContainerErrType.parseStats, message: '$e'),
);
Loggers.app.warning('Parse docker stats: $statsRaw', e, trace);
} finally {
notifyListeners();
}
}
@@ -223,25 +235,23 @@ class ContainerProvider extends ChangeNotifier {
}
Future<ContainerErr?> run(String cmd, {bool autoRefresh = true}) async {
cmd = switch (type) {
cmd = switch (state.type) {
ContainerType.docker => 'docker $cmd',
ContainerType.podman => 'podman $cmd',
};
runLog = '';
state = state.copyWith(runLog: '');
final errs = <String>[];
final code = await client?.execWithPwd(
_wrap((await sudoCompleter.future) ? 'sudo -S $cmd' : cmd),
context: context,
onStdout: (data, _) {
runLog = '$runLog$data';
notifyListeners();
state = state.copyWith(runLog: '${state.runLog}$data');
},
onStderr: (data, _) => errs.add(data),
id: hostId,
);
runLog = null;
notifyListeners();
state = state.copyWith(runLog: null);
if (code != 0) {
return ContainerErr(type: ContainerErrType.unknown, message: errs.join('\n').trim());
@@ -262,6 +272,7 @@ class ContainerProvider extends ChangeNotifier {
}
}
const _jsonFmt = '--format "{{json .}}"';
enum ContainerCmdType {

View File

@@ -0,0 +1,305 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'container.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ContainerState {
List<ContainerPs>? get items; List<ContainerImg>? get images; String? get version; ContainerErr? get error; String? get runLog; ContainerType get type; bool get isBusy;
/// Create a copy of ContainerState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ContainerStateCopyWith<ContainerState> get copyWith => _$ContainerStateCopyWithImpl<ContainerState>(this as ContainerState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ContainerState&&const DeepCollectionEquality().equals(other.items, items)&&const DeepCollectionEquality().equals(other.images, images)&&(identical(other.version, version) || other.version == version)&&(identical(other.error, error) || other.error == error)&&(identical(other.runLog, runLog) || other.runLog == runLog)&&(identical(other.type, type) || other.type == type)&&(identical(other.isBusy, isBusy) || other.isBusy == isBusy));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(items),const DeepCollectionEquality().hash(images),version,error,runLog,type,isBusy);
@override
String toString() {
return 'ContainerState(items: $items, images: $images, version: $version, error: $error, runLog: $runLog, type: $type, isBusy: $isBusy)';
}
}
/// @nodoc
abstract mixin class $ContainerStateCopyWith<$Res> {
factory $ContainerStateCopyWith(ContainerState value, $Res Function(ContainerState) _then) = _$ContainerStateCopyWithImpl;
@useResult
$Res call({
List<ContainerPs>? items, List<ContainerImg>? images, String? version, ContainerErr? error, String? runLog, ContainerType type, bool isBusy
});
}
/// @nodoc
class _$ContainerStateCopyWithImpl<$Res>
implements $ContainerStateCopyWith<$Res> {
_$ContainerStateCopyWithImpl(this._self, this._then);
final ContainerState _self;
final $Res Function(ContainerState) _then;
/// Create a copy of ContainerState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? items = freezed,Object? images = freezed,Object? version = freezed,Object? error = freezed,Object? runLog = freezed,Object? type = null,Object? isBusy = null,}) {
return _then(_self.copyWith(
items: freezed == items ? _self.items : items // ignore: cast_nullable_to_non_nullable
as List<ContainerPs>?,images: freezed == images ? _self.images : images // ignore: cast_nullable_to_non_nullable
as List<ContainerImg>?,version: freezed == version ? _self.version : version // ignore: cast_nullable_to_non_nullable
as String?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as ContainerErr?,runLog: freezed == runLog ? _self.runLog : runLog // ignore: cast_nullable_to_non_nullable
as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as ContainerType,isBusy: null == isBusy ? _self.isBusy : isBusy // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// Adds pattern-matching-related methods to [ContainerState].
extension ContainerStatePatterns on ContainerState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ContainerState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ContainerState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ContainerState value) $default,){
final _that = this;
switch (_that) {
case _ContainerState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ContainerState value)? $default,){
final _that = this;
switch (_that) {
case _ContainerState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( List<ContainerPs>? items, List<ContainerImg>? images, String? version, ContainerErr? error, String? runLog, ContainerType type, bool isBusy)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ContainerState() when $default != null:
return $default(_that.items,_that.images,_that.version,_that.error,_that.runLog,_that.type,_that.isBusy);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<ContainerPs>? items, List<ContainerImg>? images, String? version, ContainerErr? error, String? runLog, ContainerType type, bool isBusy) $default,) {final _that = this;
switch (_that) {
case _ContainerState():
return $default(_that.items,_that.images,_that.version,_that.error,_that.runLog,_that.type,_that.isBusy);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<ContainerPs>? items, List<ContainerImg>? images, String? version, ContainerErr? error, String? runLog, ContainerType type, bool isBusy)? $default,) {final _that = this;
switch (_that) {
case _ContainerState() when $default != null:
return $default(_that.items,_that.images,_that.version,_that.error,_that.runLog,_that.type,_that.isBusy);case _:
return null;
}
}
}
/// @nodoc
class _ContainerState implements ContainerState {
const _ContainerState({final List<ContainerPs>? items = null, final List<ContainerImg>? images = null, this.version = null, this.error = null, this.runLog = null, this.type = ContainerType.docker, this.isBusy = false}): _items = items,_images = images;
final List<ContainerPs>? _items;
@override@JsonKey() List<ContainerPs>? get items {
final value = _items;
if (value == null) return null;
if (_items is EqualUnmodifiableListView) return _items;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(value);
}
final List<ContainerImg>? _images;
@override@JsonKey() List<ContainerImg>? get images {
final value = _images;
if (value == null) return null;
if (_images is EqualUnmodifiableListView) return _images;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(value);
}
@override@JsonKey() final String? version;
@override@JsonKey() final ContainerErr? error;
@override@JsonKey() final String? runLog;
@override@JsonKey() final ContainerType type;
@override@JsonKey() final bool isBusy;
/// Create a copy of ContainerState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ContainerStateCopyWith<_ContainerState> get copyWith => __$ContainerStateCopyWithImpl<_ContainerState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ContainerState&&const DeepCollectionEquality().equals(other._items, _items)&&const DeepCollectionEquality().equals(other._images, _images)&&(identical(other.version, version) || other.version == version)&&(identical(other.error, error) || other.error == error)&&(identical(other.runLog, runLog) || other.runLog == runLog)&&(identical(other.type, type) || other.type == type)&&(identical(other.isBusy, isBusy) || other.isBusy == isBusy));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_items),const DeepCollectionEquality().hash(_images),version,error,runLog,type,isBusy);
@override
String toString() {
return 'ContainerState(items: $items, images: $images, version: $version, error: $error, runLog: $runLog, type: $type, isBusy: $isBusy)';
}
}
/// @nodoc
abstract mixin class _$ContainerStateCopyWith<$Res> implements $ContainerStateCopyWith<$Res> {
factory _$ContainerStateCopyWith(_ContainerState value, $Res Function(_ContainerState) _then) = __$ContainerStateCopyWithImpl;
@override @useResult
$Res call({
List<ContainerPs>? items, List<ContainerImg>? images, String? version, ContainerErr? error, String? runLog, ContainerType type, bool isBusy
});
}
/// @nodoc
class __$ContainerStateCopyWithImpl<$Res>
implements _$ContainerStateCopyWith<$Res> {
__$ContainerStateCopyWithImpl(this._self, this._then);
final _ContainerState _self;
final $Res Function(_ContainerState) _then;
/// Create a copy of ContainerState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? items = freezed,Object? images = freezed,Object? version = freezed,Object? error = freezed,Object? runLog = freezed,Object? type = null,Object? isBusy = null,}) {
return _then(_ContainerState(
items: freezed == items ? _self._items : items // ignore: cast_nullable_to_non_nullable
as List<ContainerPs>?,images: freezed == images ? _self._images : images // ignore: cast_nullable_to_non_nullable
as List<ContainerImg>?,version: freezed == version ? _self.version : version // ignore: cast_nullable_to_non_nullable
as String?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as ContainerErr?,runLog: freezed == runLog ? _self.runLog : runLog // ignore: cast_nullable_to_non_nullable
as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as ContainerType,isBusy: null == isBusy ? _self.isBusy : isBusy // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
// dart format on

View File

@@ -0,0 +1,228 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'container.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$containerNotifierHash() => r'db8f8a6b6071b7b33fbf79128dfed408a5b9fdad';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
abstract class _$ContainerNotifier
extends BuildlessAutoDisposeNotifier<ContainerState> {
late final SSHClient? client;
late final String userName;
late final String hostId;
late final BuildContext context;
ContainerState build(
SSHClient? client,
String userName,
String hostId,
BuildContext context,
);
}
/// See also [ContainerNotifier].
@ProviderFor(ContainerNotifier)
const containerNotifierProvider = ContainerNotifierFamily();
/// See also [ContainerNotifier].
class ContainerNotifierFamily extends Family<ContainerState> {
/// See also [ContainerNotifier].
const ContainerNotifierFamily();
/// See also [ContainerNotifier].
ContainerNotifierProvider call(
SSHClient? client,
String userName,
String hostId,
BuildContext context,
) {
return ContainerNotifierProvider(client, userName, hostId, context);
}
@override
ContainerNotifierProvider getProviderOverride(
covariant ContainerNotifierProvider provider,
) {
return call(
provider.client,
provider.userName,
provider.hostId,
provider.context,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'containerNotifierProvider';
}
/// See also [ContainerNotifier].
class ContainerNotifierProvider
extends AutoDisposeNotifierProviderImpl<ContainerNotifier, ContainerState> {
/// See also [ContainerNotifier].
ContainerNotifierProvider(
SSHClient? client,
String userName,
String hostId,
BuildContext context,
) : this._internal(
() => ContainerNotifier()
..client = client
..userName = userName
..hostId = hostId
..context = context,
from: containerNotifierProvider,
name: r'containerNotifierProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$containerNotifierHash,
dependencies: ContainerNotifierFamily._dependencies,
allTransitiveDependencies:
ContainerNotifierFamily._allTransitiveDependencies,
client: client,
userName: userName,
hostId: hostId,
context: context,
);
ContainerNotifierProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.client,
required this.userName,
required this.hostId,
required this.context,
}) : super.internal();
final SSHClient? client;
final String userName;
final String hostId;
final BuildContext context;
@override
ContainerState runNotifierBuild(covariant ContainerNotifier notifier) {
return notifier.build(client, userName, hostId, context);
}
@override
Override overrideWith(ContainerNotifier Function() create) {
return ProviderOverride(
origin: this,
override: ContainerNotifierProvider._internal(
() => create()
..client = client
..userName = userName
..hostId = hostId
..context = context,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
client: client,
userName: userName,
hostId: hostId,
context: context,
),
);
}
@override
AutoDisposeNotifierProviderElement<ContainerNotifier, ContainerState>
createElement() {
return _ContainerNotifierProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is ContainerNotifierProvider &&
other.client == client &&
other.userName == userName &&
other.hostId == hostId &&
other.context == context;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, client.hashCode);
hash = _SystemHash.combine(hash, userName.hashCode);
hash = _SystemHash.combine(hash, hostId.hashCode);
hash = _SystemHash.combine(hash, context.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin ContainerNotifierRef on AutoDisposeNotifierProviderRef<ContainerState> {
/// The parameter `client` of this provider.
SSHClient? get client;
/// The parameter `userName` of this provider.
String get userName;
/// The parameter `hostId` of this provider.
String get hostId;
/// The parameter `context` of this provider.
BuildContext get context;
}
class _ContainerNotifierProviderElement
extends
AutoDisposeNotifierProviderElement<ContainerNotifier, ContainerState>
with ContainerNotifierRef {
_ContainerNotifierProviderElement(super.provider);
@override
SSHClient? get client => (origin as ContainerNotifierProvider).client;
@override
String get userName => (origin as ContainerNotifierProvider).userName;
@override
String get hostId => (origin as ContainerNotifierProvider).hostId;
@override
BuildContext get context => (origin as ContainerNotifierProvider).context;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -1,45 +1,61 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.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';
class PrivateKeyProvider extends Provider {
const PrivateKeyProvider._();
static const instance = PrivateKeyProvider._();
part 'private_key.freezed.dart';
part 'private_key.g.dart';
static final pkis = <PrivateKeyInfo>[].vn;
@freezed
abstract class PrivateKeyState with _$PrivateKeyState {
const factory PrivateKeyState({@Default(<PrivateKeyInfo>[]) List<PrivateKeyInfo> keys}) = _PrivateKeyState;
}
@Riverpod(keepAlive: true)
class PrivateKeyNotifier extends _$PrivateKeyNotifier {
@override
void load() {
super.load();
pkis.value = Stores.key.fetch();
PrivateKeyState build() {
return _load();
}
static void add(PrivateKeyInfo info) {
pkis.value.add(info);
pkis.notify();
void reload() {
final newState = _load();
if (newState == state) return;
state = newState;
}
PrivateKeyState _load() {
final keys = Stores.key.fetch();
return stateOrNull?.copyWith(keys: keys) ?? PrivateKeyState(keys: keys);
}
void add(PrivateKeyInfo info) {
final newKeys = [...state.keys, info];
state = state.copyWith(keys: newKeys);
Stores.key.put(info);
bakSync.sync(milliDelay: 1000);
}
static void delete(PrivateKeyInfo info) {
pkis.value.removeWhere((e) => e.id == info.id);
pkis.notify();
void delete(PrivateKeyInfo info) {
final newKeys = state.keys.where((e) => e.id != info.id).toList();
state = state.copyWith(keys: newKeys);
Stores.key.delete(info);
bakSync.sync(milliDelay: 1000);
}
static void update(PrivateKeyInfo old, PrivateKeyInfo newInfo) {
final idx = pkis.value.indexWhere((e) => e.id == old.id);
void update(PrivateKeyInfo old, PrivateKeyInfo newInfo) {
final keys = [...state.keys];
final idx = keys.indexWhere((e) => e.id == old.id);
if (idx == -1) {
pkis.value.add(newInfo);
keys.add(newInfo);
Stores.key.put(newInfo);
Stores.key.delete(old);
} else {
pkis.value[idx] = newInfo;
keys[idx] = newInfo;
Stores.key.put(newInfo);
}
pkis.notify();
state = state.copyWith(keys: keys);
bakSync.sync(milliDelay: 1000);
}
}

View File

@@ -0,0 +1,277 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'private_key.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$PrivateKeyState {
List<PrivateKeyInfo> get keys;
/// Create a copy of PrivateKeyState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$PrivateKeyStateCopyWith<PrivateKeyState> get copyWith => _$PrivateKeyStateCopyWithImpl<PrivateKeyState>(this as PrivateKeyState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is PrivateKeyState&&const DeepCollectionEquality().equals(other.keys, keys));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(keys));
@override
String toString() {
return 'PrivateKeyState(keys: $keys)';
}
}
/// @nodoc
abstract mixin class $PrivateKeyStateCopyWith<$Res> {
factory $PrivateKeyStateCopyWith(PrivateKeyState value, $Res Function(PrivateKeyState) _then) = _$PrivateKeyStateCopyWithImpl;
@useResult
$Res call({
List<PrivateKeyInfo> keys
});
}
/// @nodoc
class _$PrivateKeyStateCopyWithImpl<$Res>
implements $PrivateKeyStateCopyWith<$Res> {
_$PrivateKeyStateCopyWithImpl(this._self, this._then);
final PrivateKeyState _self;
final $Res Function(PrivateKeyState) _then;
/// Create a copy of PrivateKeyState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? keys = null,}) {
return _then(_self.copyWith(
keys: null == keys ? _self.keys : keys // ignore: cast_nullable_to_non_nullable
as List<PrivateKeyInfo>,
));
}
}
/// Adds pattern-matching-related methods to [PrivateKeyState].
extension PrivateKeyStatePatterns on PrivateKeyState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _PrivateKeyState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _PrivateKeyState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _PrivateKeyState value) $default,){
final _that = this;
switch (_that) {
case _PrivateKeyState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _PrivateKeyState value)? $default,){
final _that = this;
switch (_that) {
case _PrivateKeyState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( List<PrivateKeyInfo> keys)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _PrivateKeyState() when $default != null:
return $default(_that.keys);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<PrivateKeyInfo> keys) $default,) {final _that = this;
switch (_that) {
case _PrivateKeyState():
return $default(_that.keys);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<PrivateKeyInfo> keys)? $default,) {final _that = this;
switch (_that) {
case _PrivateKeyState() when $default != null:
return $default(_that.keys);case _:
return null;
}
}
}
/// @nodoc
class _PrivateKeyState implements PrivateKeyState {
const _PrivateKeyState({final List<PrivateKeyInfo> keys = const <PrivateKeyInfo>[]}): _keys = keys;
final List<PrivateKeyInfo> _keys;
@override@JsonKey() List<PrivateKeyInfo> get keys {
if (_keys is EqualUnmodifiableListView) return _keys;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_keys);
}
/// Create a copy of PrivateKeyState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$PrivateKeyStateCopyWith<_PrivateKeyState> get copyWith => __$PrivateKeyStateCopyWithImpl<_PrivateKeyState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PrivateKeyState&&const DeepCollectionEquality().equals(other._keys, _keys));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_keys));
@override
String toString() {
return 'PrivateKeyState(keys: $keys)';
}
}
/// @nodoc
abstract mixin class _$PrivateKeyStateCopyWith<$Res> implements $PrivateKeyStateCopyWith<$Res> {
factory _$PrivateKeyStateCopyWith(_PrivateKeyState value, $Res Function(_PrivateKeyState) _then) = __$PrivateKeyStateCopyWithImpl;
@override @useResult
$Res call({
List<PrivateKeyInfo> keys
});
}
/// @nodoc
class __$PrivateKeyStateCopyWithImpl<$Res>
implements _$PrivateKeyStateCopyWith<$Res> {
__$PrivateKeyStateCopyWithImpl(this._self, this._then);
final _PrivateKeyState _self;
final $Res Function(_PrivateKeyState) _then;
/// Create a copy of PrivateKeyState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? keys = null,}) {
return _then(_PrivateKeyState(
keys: null == keys ? _self._keys : keys // ignore: cast_nullable_to_non_nullable
as List<PrivateKeyInfo>,
));
}
}
// dart format on

View File

@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'private_key.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$privateKeyNotifierHash() =>
r'404836a4409f64d305c1e22f4a57b52985a57b68';
/// See also [PrivateKeyNotifier].
@ProviderFor(PrivateKeyNotifier)
final privateKeyNotifierProvider =
NotifierProvider<PrivateKeyNotifier, PrivateKeyState>.internal(
PrivateKeyNotifier.new,
name: r'privateKeyNotifierProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$privateKeyNotifierHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$PrivateKeyNotifier = Notifier<PrivateKeyState>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -0,0 +1,82 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:server_box/data/provider/app.dart';
import 'package:server_box/data/provider/private_key.dart';
import 'package:server_box/data/provider/server/all.dart';
import 'package:server_box/data/provider/sftp.dart';
import 'package:server_box/data/provider/snippet.dart';
/// !library;
/// ref.useNotifier, ref.readProvider, ref.watchProvider
///
/// Usage:
/// - `providers.read.server` -> `ref.read(serversNotifierProvider)`
/// - `providers.use.snippet` -> `ref.read(snippetsNotifierProvider.notifier)`
extension RiverpodNotifiers on ConsumerState {
T useNotifier<T extends Notifier<Object?>>(NotifierProvider<T, Object?> provider) {
return ref.read(provider.notifier);
}
T readProvider<T>(ProviderBase<T> provider) {
return ref.read(provider);
}
T watchProvider<T>(ProviderBase<T> provider) {
return ref.watch(provider);
}
MyProviders get providers => MyProviders(ref);
}
final class MyProviders {
final WidgetRef ref;
const MyProviders(this.ref);
ReadMyProvider get read => ReadMyProvider(ref);
WatchMyProvider get watch => WatchMyProvider(ref);
UseNotifierMyProvider get use => UseNotifierMyProvider(ref);
}
final class ReadMyProvider {
final WidgetRef ref;
const ReadMyProvider(this.ref);
T call<T>(ProviderBase<T> provider) => ref.read(provider);
// Specific provider getters
ServersState get server => ref.read(serversNotifierProvider);
SnippetState get snippet => ref.read(snippetNotifierProvider);
AppState get app => ref.read(appStatesProvider);
PrivateKeyState get privateKey => ref.read(privateKeyNotifierProvider);
SftpState get sftp => ref.read(sftpNotifierProvider);
}
final class WatchMyProvider {
final WidgetRef ref;
const WatchMyProvider(this.ref);
T call<T>(ProviderBase<T> provider) => ref.watch(provider);
// Specific provider getters
ServersState get server => ref.watch(serversNotifierProvider);
SnippetState get snippet => ref.watch(snippetNotifierProvider);
AppState get app => ref.watch(appStatesProvider);
PrivateKeyState get privateKey => ref.watch(privateKeyNotifierProvider);
SftpState get sftp => ref.watch(sftpNotifierProvider);
}
final class UseNotifierMyProvider {
final WidgetRef ref;
const UseNotifierMyProvider(this.ref);
T call<T extends Notifier<Object?>>(NotifierProvider<T, Object?> provider) =>
ref.read(provider.notifier);
// Specific provider notifier getters
ServersNotifier get server => ref.read(serversNotifierProvider.notifier);
SnippetNotifier get snippet => ref.read(snippetNotifierProvider.notifier);
AppStates get app => ref.read(appStatesProvider.notifier);
PrivateKeyNotifier get privateKey => ref.read(privateKeyNotifierProvider.notifier);
SftpNotifier get sftp => ref.read(sftpNotifierProvider.notifier);
}

View File

@@ -7,71 +7,90 @@ import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/server/pve.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/provider/server/single.dart';
part 'pve.freezed.dart';
part 'pve.g.dart';
typedef PveCtrlFunc = Future<bool> Function(String node, String id);
final class PveProvider extends ChangeNotifier {
final Spi spi;
@freezed
abstract class PveState with _$PveState {
const factory PveState({
@Default(null) PveErr? error,
@Default(null) PveRes? data,
@Default(null) String? release,
@Default(false) bool isBusy,
@Default(false) bool isConnected,
}) = _PveState;
}
@riverpod
class PveNotifier extends _$PveNotifier {
late final Spi spi;
late String addr;
late final SSHClient _client;
late final ServerSocket _serverSocket;
final List<SSHForwardChannel> _forwards = [];
int _localPort = 0;
late final Dio session;
late final bool _ignoreCert;
PveProvider({required this.spi}) {
final client = spi.server?.value.client;
@override
PveState build(Spi spiParam) {
spi = spiParam;
final serverState = ref.watch(serverNotifierProvider(spi.id));
final client = serverState.client;
if (client == null) {
throw Exception('Server client is null');
return const PveState(error: PveErr(type: PveErrType.net, message: 'Server client is null'));
}
_client = client;
final addr = spi.custom?.pveAddr;
if (addr == null) {
err.value = 'PVE address is null';
return;
return PveState(error: PveErr(type: PveErrType.net, message: 'PVE address is null'));
}
this.addr = addr;
_init();
_ignoreCert = spi.custom?.pveIgnoreCert ?? false;
_initSession();
// Async initialization
Future.microtask(() => _init());
return const PveState();
}
final err = ValueNotifier<String?>(null);
final connected = Completer<void>();
void _initSession() {
session = Dio()
..httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
final client = HttpClient();
client.connectionFactory = cf;
if (_ignoreCert) {
client.badCertificateCallback = (_, _, _) => true;
}
return client;
},
validateCertificate: _ignoreCert ? (_, _, _) => true : null,
);
}
late final _ignoreCert = spi.custom?.pveIgnoreCert ?? false;
late final session = Dio()
..httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
final client = HttpClient();
client.connectionFactory = cf;
if (_ignoreCert) {
client.badCertificateCallback = (_, _, _) => true;
}
return client;
},
validateCertificate: _ignoreCert ? (_, _, _) => true : null,
);
final data = ValueNotifier<PveRes?>(null);
bool get onlyOneNode => data.value?.nodes.length == 1;
String? release;
bool isBusy = false;
bool get onlyOneNode => state.data?.nodes.length == 1;
Future<void> _init() async {
try {
await _forward();
await _login();
await _getRelease();
state = state.copyWith(isConnected: true);
} on PveErr {
err.value = l10n.pveLoginFailed;
state = state.copyWith(error: PveErr(type: PveErrType.loginFailed, message: l10n.pveLoginFailed));
} catch (e, s) {
Loggers.app.warning('PVE init failed', e, s);
err.value = e.toString();
} finally {
connected.complete();
state = state.copyWith(error: PveErr(type: PveErrType.unknown, message: e.toString()));
}
}
@@ -146,72 +165,81 @@ final class PveProvider extends ChangeNotifier {
final resp = await session.get('$addr/api2/extjs/version');
final version = resp.data['data']['release'] as String?;
if (version != null) {
release = version;
state = state.copyWith(release: version);
}
}
Future<void> list() async {
await connected.future;
if (isBusy) return;
isBusy = true;
if (!state.isConnected || state.isBusy) return;
state = state.copyWith(isBusy: true);
try {
final resp = await session.get('$addr/api2/json/cluster/resources');
final res = resp.data['data'] as List;
final result = await Computer.shared.start(PveRes.parse, (
res,
data.value,
state.data,
));
data.value = result;
state = state.copyWith(data: result, error: null);
} catch (e) {
Loggers.app.warning('PVE list failed', e);
err.value = e.toString();
state = state.copyWith(error: PveErr(type: PveErrType.unknown, message: e.toString()));
} finally {
isBusy = false;
state = state.copyWith(isBusy: false);
}
}
Future<bool> reboot(String node, String id) async {
await connected.future;
if (!state.isConnected) return false;
final resp = await session.post(
'$addr/api2/json/nodes/$node/$id/status/reboot',
);
return _isCtrlSuc(resp);
final success = _isCtrlSuc(resp);
if (success) await list(); // Refresh data
return success;
}
Future<bool> start(String node, String id) async {
await connected.future;
if (!state.isConnected) return false;
final resp = await session.post(
'$addr/api2/json/nodes/$node/$id/status/start',
);
return _isCtrlSuc(resp);
final success = _isCtrlSuc(resp);
if (success) await list(); // Refresh data
return success;
}
Future<bool> stop(String node, String id) async {
await connected.future;
if (!state.isConnected) return false;
final resp = await session.post(
'$addr/api2/json/nodes/$node/$id/status/stop',
);
return _isCtrlSuc(resp);
final success = _isCtrlSuc(resp);
if (success) await list(); // Refresh data
return success;
}
Future<bool> shutdown(String node, String id) async {
await connected.future;
if (!state.isConnected) return false;
final resp = await session.post(
'$addr/api2/json/nodes/$node/$id/status/shutdown',
);
return _isCtrlSuc(resp);
final success = _isCtrlSuc(resp);
if (success) await list(); // Refresh data
return success;
}
bool _isCtrlSuc(Response resp) {
return resp.statusCode == 200;
}
@override
Future<void> dispose() async {
super.dispose();
await _serverSocket.close();
try {
await _serverSocket.close();
} catch (_) {}
for (final forward in _forwards) {
forward.close();
try {
forward.close();
} catch (_) {}
}
}
}

View File

@@ -0,0 +1,283 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'pve.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$PveState {
PveErr? get error; PveRes? get data; String? get release; bool get isBusy; bool get isConnected;
/// Create a copy of PveState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$PveStateCopyWith<PveState> get copyWith => _$PveStateCopyWithImpl<PveState>(this as PveState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is PveState&&(identical(other.error, error) || other.error == error)&&(identical(other.data, data) || other.data == data)&&(identical(other.release, release) || other.release == release)&&(identical(other.isBusy, isBusy) || other.isBusy == isBusy)&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected));
}
@override
int get hashCode => Object.hash(runtimeType,error,data,release,isBusy,isConnected);
@override
String toString() {
return 'PveState(error: $error, data: $data, release: $release, isBusy: $isBusy, isConnected: $isConnected)';
}
}
/// @nodoc
abstract mixin class $PveStateCopyWith<$Res> {
factory $PveStateCopyWith(PveState value, $Res Function(PveState) _then) = _$PveStateCopyWithImpl;
@useResult
$Res call({
PveErr? error, PveRes? data, String? release, bool isBusy, bool isConnected
});
}
/// @nodoc
class _$PveStateCopyWithImpl<$Res>
implements $PveStateCopyWith<$Res> {
_$PveStateCopyWithImpl(this._self, this._then);
final PveState _self;
final $Res Function(PveState) _then;
/// Create a copy of PveState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? error = freezed,Object? data = freezed,Object? release = freezed,Object? isBusy = null,Object? isConnected = null,}) {
return _then(_self.copyWith(
error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as PveErr?,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as PveRes?,release: freezed == release ? _self.release : release // ignore: cast_nullable_to_non_nullable
as String?,isBusy: null == isBusy ? _self.isBusy : isBusy // ignore: cast_nullable_to_non_nullable
as bool,isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// Adds pattern-matching-related methods to [PveState].
extension PveStatePatterns on PveState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _PveState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _PveState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _PveState value) $default,){
final _that = this;
switch (_that) {
case _PveState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _PveState value)? $default,){
final _that = this;
switch (_that) {
case _PveState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( PveErr? error, PveRes? data, String? release, bool isBusy, bool isConnected)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _PveState() when $default != null:
return $default(_that.error,_that.data,_that.release,_that.isBusy,_that.isConnected);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( PveErr? error, PveRes? data, String? release, bool isBusy, bool isConnected) $default,) {final _that = this;
switch (_that) {
case _PveState():
return $default(_that.error,_that.data,_that.release,_that.isBusy,_that.isConnected);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( PveErr? error, PveRes? data, String? release, bool isBusy, bool isConnected)? $default,) {final _that = this;
switch (_that) {
case _PveState() when $default != null:
return $default(_that.error,_that.data,_that.release,_that.isBusy,_that.isConnected);case _:
return null;
}
}
}
/// @nodoc
class _PveState implements PveState {
const _PveState({this.error = null, this.data = null, this.release = null, this.isBusy = false, this.isConnected = false});
@override@JsonKey() final PveErr? error;
@override@JsonKey() final PveRes? data;
@override@JsonKey() final String? release;
@override@JsonKey() final bool isBusy;
@override@JsonKey() final bool isConnected;
/// Create a copy of PveState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$PveStateCopyWith<_PveState> get copyWith => __$PveStateCopyWithImpl<_PveState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PveState&&(identical(other.error, error) || other.error == error)&&(identical(other.data, data) || other.data == data)&&(identical(other.release, release) || other.release == release)&&(identical(other.isBusy, isBusy) || other.isBusy == isBusy)&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected));
}
@override
int get hashCode => Object.hash(runtimeType,error,data,release,isBusy,isConnected);
@override
String toString() {
return 'PveState(error: $error, data: $data, release: $release, isBusy: $isBusy, isConnected: $isConnected)';
}
}
/// @nodoc
abstract mixin class _$PveStateCopyWith<$Res> implements $PveStateCopyWith<$Res> {
factory _$PveStateCopyWith(_PveState value, $Res Function(_PveState) _then) = __$PveStateCopyWithImpl;
@override @useResult
$Res call({
PveErr? error, PveRes? data, String? release, bool isBusy, bool isConnected
});
}
/// @nodoc
class __$PveStateCopyWithImpl<$Res>
implements _$PveStateCopyWith<$Res> {
__$PveStateCopyWithImpl(this._self, this._then);
final _PveState _self;
final $Res Function(_PveState) _then;
/// Create a copy of PveState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? error = freezed,Object? data = freezed,Object? release = freezed,Object? isBusy = null,Object? isConnected = null,}) {
return _then(_PveState(
error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as PveErr?,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as PveRes?,release: freezed == release ? _self.release : release // ignore: cast_nullable_to_non_nullable
as String?,isBusy: null == isBusy ? _self.isBusy : isBusy // ignore: cast_nullable_to_non_nullable
as bool,isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
// dart format on

View File

@@ -0,0 +1,160 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'pve.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$pveNotifierHash() => r'b5da7240db1b9ee7d61f238cebca45821b7a3445';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
abstract class _$PveNotifier extends BuildlessAutoDisposeNotifier<PveState> {
late final Spi spiParam;
PveState build(Spi spiParam);
}
/// See also [PveNotifier].
@ProviderFor(PveNotifier)
const pveNotifierProvider = PveNotifierFamily();
/// See also [PveNotifier].
class PveNotifierFamily extends Family<PveState> {
/// See also [PveNotifier].
const PveNotifierFamily();
/// See also [PveNotifier].
PveNotifierProvider call(Spi spiParam) {
return PveNotifierProvider(spiParam);
}
@override
PveNotifierProvider getProviderOverride(
covariant PveNotifierProvider provider,
) {
return call(provider.spiParam);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'pveNotifierProvider';
}
/// See also [PveNotifier].
class PveNotifierProvider
extends AutoDisposeNotifierProviderImpl<PveNotifier, PveState> {
/// See also [PveNotifier].
PveNotifierProvider(Spi spiParam)
: this._internal(
() => PveNotifier()..spiParam = spiParam,
from: pveNotifierProvider,
name: r'pveNotifierProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$pveNotifierHash,
dependencies: PveNotifierFamily._dependencies,
allTransitiveDependencies: PveNotifierFamily._allTransitiveDependencies,
spiParam: spiParam,
);
PveNotifierProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.spiParam,
}) : super.internal();
final Spi spiParam;
@override
PveState runNotifierBuild(covariant PveNotifier notifier) {
return notifier.build(spiParam);
}
@override
Override overrideWith(PveNotifier Function() create) {
return ProviderOverride(
origin: this,
override: PveNotifierProvider._internal(
() => create()..spiParam = spiParam,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
spiParam: spiParam,
),
);
}
@override
AutoDisposeNotifierProviderElement<PveNotifier, PveState> createElement() {
return _PveNotifierProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is PveNotifierProvider && other.spiParam == spiParam;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, spiParam.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin PveNotifierRef on AutoDisposeNotifierProviderRef<PveState> {
/// The parameter `spiParam` of this provider.
Spi get spiParam;
}
class _PveNotifierProviderElement
extends AutoDisposeNotifierProviderElement<PveNotifier, PveState>
with PveNotifierRef {
_PveNotifierProviderElement(super.provider);
@override
Spi get spiParam => (origin as PveNotifierProvider).spiParam;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -1,505 +0,0 @@
import 'dart:async';
// import 'dart:io';
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/server.dart';
import 'package:server_box/core/utils/ssh_auth.dart';
import 'package:server_box/data/helper/system_detector.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/app/scripts/script_consts.dart';
import 'package:server_box/data/model/app/scripts/shell_func.dart';
import 'package:server_box/data/model/server/server.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/server_status_update_req.dart';
import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/model/server/try_limiter.dart';
import 'package:server_box/data/res/status.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/data/ssh/session_manager.dart';
class ServerProvider extends Provider {
const ServerProvider._();
static const instance = ServerProvider._();
static final Map<String, VNode<Server>> servers = {};
static final serverOrder = <String>[].vn;
static final _tags = <String>{}.vn;
static VNode<Set<String>> get tags => _tags;
static Timer? _timer;
static final _manualDisconnectedIds = <String>{};
static final _serverIdsUpdating = <String, Future<void>?>{};
@override
Future<void> load() async {
super.load();
// #147
// Clear all servers because of restarting app will cause duplicate servers
final oldServers = Map<String, VNode<Server>>.from(servers);
servers.clear();
serverOrder.value.clear();
final spis = Stores.server.fetch();
for (int idx = 0; idx < spis.length; idx++) {
final spi = spis[idx];
final originServer = oldServers[spi.id];
/// #258
/// If not [shouldReconnect], then keep the old state.
if (originServer != null && !originServer.value.spi.shouldReconnect(spi)) {
originServer.value.spi = spi;
servers[spi.id] = originServer;
} else {
final newServer = genServer(spi);
servers[spi.id] = newServer.vn;
}
}
final serverOrder_ = Stores.setting.serverOrder.fetch();
if (serverOrder_.isNotEmpty) {
spis.reorder(order: serverOrder_, finder: (n, id) => n.id == id);
serverOrder.value.addAll(spis.map((e) => e.id));
} else {
serverOrder.value.addAll(servers.keys);
}
// Must use [equals] to compare [Order] here.
if (!serverOrder.value.equals(serverOrder_)) {
Stores.setting.serverOrder.put(serverOrder.value);
}
_updateTags();
// Must notify here, or the UI will not be updated.
serverOrder.notify();
}
/// Get a [Server] by [spi] or [id].
///
/// Priority: [spi] > [id]
static VNode<Server>? pick({Spi? spi, String? id}) {
if (spi != null) {
return servers[spi.id];
}
if (id != null) {
return servers[id];
}
return null;
}
static void _updateTags() {
final tags = <String>{};
for (final s in servers.values) {
final spiTags = s.value.spi.tags;
if (spiTags == null) continue;
for (final t in spiTags) {
tags.add(t);
}
}
_tags.value = tags;
}
static Server genServer(Spi spi) {
return Server(spi, InitStatus.status, ServerConn.disconnected);
}
/// if [spi] is specificed then only refresh this server
/// [onlyFailed] only refresh failed servers
static Future<void> refresh({Spi? spi, bool onlyFailed = false}) async {
if (spi != null) {
_manualDisconnectedIds.remove(spi.id);
await _getData(spi);
return;
}
await Future.wait(
servers.values.map((val) async {
final s = val.value;
if (onlyFailed) {
if (s.conn != ServerConn.failed) return;
TryLimiter.reset(s.spi.id);
}
if (_manualDisconnectedIds.contains(s.spi.id)) return;
if (s.conn == ServerConn.disconnected && !s.spi.autoConnect) {
return;
}
// Check if already updating, and if so, wait for it to complete
final existingUpdate = _serverIdsUpdating[s.spi.id];
if (existingUpdate != null) {
// Already updating, wait for the existing update to complete
try {
await existingUpdate;
} catch (e) {
// Ignore errors from the existing update, we'll try our own
}
return;
}
// Start a new update operation
final updateFuture = _updateServer(s.spi);
_serverIdsUpdating[s.spi.id] = updateFuture;
try {
await updateFuture;
} finally {
_serverIdsUpdating.remove(s.spi.id);
}
}),
);
}
static Future<void> _updateServer(Spi spi) async {
await _getData(spi);
}
static Future<void> startAutoRefresh() async {
var duration = Stores.setting.serverStatusUpdateInterval.fetch();
stopAutoRefresh();
if (duration == 0) return;
if (duration < 0 || duration > 10 || duration == 1) {
duration = 3;
Loggers.app.warning('Invalid duration: $duration, use default 3');
}
_timer = Timer.periodic(Duration(seconds: duration), (_) async {
await refresh();
});
}
static void stopAutoRefresh() {
if (_timer != null) {
_timer!.cancel();
_timer = null;
}
}
static bool get isAutoRefreshOn => _timer != null;
static void setDisconnected() {
for (final s in servers.values) {
s.value.conn = ServerConn.disconnected;
s.notify();
// Update SSH session status to disconnected
final sessionId = 'ssh_${s.value.spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
}
//TryLimiter.clear();
}
static void closeServer({String? id}) {
if (id == null) {
for (final s in servers.values) {
_closeOneServer(s.value.spi.id);
}
return;
}
_closeOneServer(id);
}
static void _closeOneServer(String id) {
final s = servers[id];
if (s == null) {
Loggers.app.warning('Server with id $id not found');
return;
}
final item = s.value;
item.client?.close();
item.client = null;
item.conn = ServerConn.disconnected;
_manualDisconnectedIds.add(id);
s.notify();
// Remove SSH session when server is manually closed
final sessionId = 'ssh_$id';
TermSessionManager.remove(sessionId);
}
static void addServer(Spi spi) {
servers[spi.id] = genServer(spi).vn;
Stores.server.put(spi);
serverOrder.value.add(spi.id);
serverOrder.notify();
Stores.setting.serverOrder.put(serverOrder.value);
_updateTags();
refresh(spi: spi);
bakSync.sync(milliDelay: 1000);
}
static void delServer(String id) {
servers.remove(id);
serverOrder.value.remove(id);
serverOrder.notify();
Stores.setting.serverOrder.put(serverOrder.value);
Stores.server.delete(id);
_updateTags();
// Remove SSH session when server is deleted
final sessionId = 'ssh_$id';
TermSessionManager.remove(sessionId);
bakSync.sync(milliDelay: 1000);
}
static void deleteAll() {
// Remove all SSH sessions before clearing servers
for (final id in servers.keys) {
final sessionId = 'ssh_$id';
TermSessionManager.remove(sessionId);
}
servers.clear();
serverOrder.value.clear();
serverOrder.notify();
Stores.setting.serverOrder.put(serverOrder.value);
Stores.server.clear();
_updateTags();
bakSync.sync(milliDelay: 1000);
}
static Future<void> updateServer(Spi old, Spi newSpi) async {
if (old != newSpi) {
Stores.server.update(old, newSpi);
servers[old.id]?.value.spi = newSpi;
if (newSpi.id != old.id) {
servers[newSpi.id] = servers[old.id]!;
servers.remove(old.id);
serverOrder.value.update(old.id, newSpi.id);
Stores.setting.serverOrder.put(serverOrder.value);
serverOrder.notify();
// Update SSH session ID when server ID changes
final oldSessionId = 'ssh_${old.id}';
TermSessionManager.remove(oldSessionId);
// Session will be re-added when reconnecting if necessary
}
// Only reconnect if neccessary
if (newSpi.shouldReconnect(old)) {
// Use [newSpi.id] instead of [old.id] because [old.id] may be changed
TryLimiter.reset(newSpi.id);
refresh(spi: newSpi);
}
}
_updateTags();
bakSync.sync(milliDelay: 1000);
}
static void _setServerState(VNode<Server> s, ServerConn ss) {
s.value.conn = ss;
s.notify();
}
static Future<void> _getData(Spi spi) async {
final sid = spi.id;
final s = servers[sid];
if (s == null) return;
final sv = s.value;
if (!TryLimiter.canTry(sid)) {
if (sv.conn != ServerConn.failed) {
_setServerState(s, ServerConn.failed);
}
return;
}
sv.status.err = null;
if (sv.needGenClient || (sv.client?.isClosed ?? true)) {
_setServerState(s, ServerConn.connecting);
final wol = spi.wolCfg;
if (wol != null) {
try {
await wol.wake();
} catch (e) {
// TryLimiter.inc(sid);
// s.status.err = SSHErr(
// type: SSHErrType.connect,
// message: 'Wake on lan failed: $e',
// );
// _setServerState(s, ServerConn.failed);
Loggers.app.warning('Wake on lan failed', e);
// return;
}
}
try {
final time1 = DateTime.now();
sv.client = await genClient(
spi,
timeout: Duration(seconds: Stores.setting.timeout.fetch()),
onKeyboardInteractive: (_) => KeybordInteractive.defaultHandle(spi),
);
final time2 = DateTime.now();
final spentTime = time2.difference(time1).inMilliseconds;
if (spi.jumpId == null) {
Loggers.app.info('Connected to ${spi.name} in $spentTime ms.');
} else {
Loggers.app.info('Jump to ${spi.name} in $spentTime ms.');
}
// Add SSH session to TermSessionManager
final sessionId = 'ssh_${spi.id}';
TermSessionManager.add(
id: sessionId,
spi: spi,
startTimeMs: time1.millisecondsSinceEpoch,
disconnect: () => _closeOneServer(spi.id),
status: TermSessionStatus.connecting,
);
TermSessionManager.setActive(sessionId, hasTerminal: false);
} catch (e) {
TryLimiter.inc(sid);
sv.status.err = SSHErr(type: SSHErrType.connect, message: e.toString());
_setServerState(s, ServerConn.failed);
// Remove SSH session on connection failure
final sessionId = 'ssh_${spi.id}';
TermSessionManager.remove(sessionId);
/// In order to keep privacy, print [spi.name] instead of [spi.id]
Loggers.app.warning('Connect to ${spi.name} failed', e);
return;
}
_setServerState(s, ServerConn.connected);
// Update SSH session status to connected
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.connected);
try {
// Detect system type using helper
final detectedSystemType = await SystemDetector.detect(sv.client!, spi);
sv.status.system = detectedSystemType;
final (_, writeScriptResult) = await sv.client!.exec((session) async {
final scriptRaw = ShellFuncManager.allScript(
spi.custom?.cmds,
systemType: detectedSystemType,
disabledCmdTypes: spi.disabledCmdTypes,
).uint8List;
session.stdin.add(scriptRaw);
session.stdin.close();
}, entry: ShellFuncManager.getInstallShellCmd(spi.id, systemType: detectedSystemType));
if (writeScriptResult.isNotEmpty && detectedSystemType != SystemType.windows) {
ShellFuncManager.switchScriptDir(spi.id, systemType: detectedSystemType);
throw writeScriptResult;
}
} on SSHAuthAbortError catch (e) {
TryLimiter.inc(sid);
final err = SSHErr(type: SSHErrType.auth, message: e.toString());
sv.status.err = err;
Loggers.app.warning(err);
_setServerState(s, ServerConn.failed);
// Update SSH session status to disconnected
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
} on SSHAuthFailError catch (e) {
TryLimiter.inc(sid);
final err = SSHErr(type: SSHErrType.auth, message: e.toString());
sv.status.err = err;
Loggers.app.warning(err);
_setServerState(s, ServerConn.failed);
// Update SSH session status to disconnected
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
} catch (e) {
// If max try times < 2 and can't write script, this will stop the status getting and etc.
// TryLimiter.inc(sid);
final err = SSHErr(type: SSHErrType.writeScript, message: e.toString());
sv.status.err = err;
Loggers.app.warning(err);
_setServerState(s, ServerConn.failed);
// Update SSH session status to disconnected
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
}
}
if (sv.conn == ServerConn.connecting) return;
/// Keep [finished] state, or the UI will be refreshed to [loading] state
/// instead of the '$Temp | $Uptime'.
/// eg: '32C | 7 days'
if (sv.conn != ServerConn.finished) {
_setServerState(s, ServerConn.loading);
}
List<String>? segments;
String? raw;
try {
raw = await sv.client?.run(ShellFunc.status.exec(spi.id, systemType: sv.status.system)).string;
//dprint('Get status from ${spi.name}:\n$raw');
segments = raw?.split(ScriptConstants.separator).map((e) => e.trim()).toList();
if (raw == null || raw.isEmpty || segments == null || segments.isEmpty) {
if (Stores.setting.keepStatusWhenErr.fetch()) {
// Keep previous server status when err occurs
if (sv.conn != ServerConn.failed && sv.status.more.isNotEmpty) {
return;
}
}
TryLimiter.inc(sid);
sv.status.err = SSHErr(type: SSHErrType.segements, message: 'Seperate segments failed, raw:\n$raw');
_setServerState(s, ServerConn.failed);
// Update SSH session status to disconnected on segments error
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
}
} catch (e) {
TryLimiter.inc(sid);
sv.status.err = SSHErr(type: SSHErrType.getStatus, message: e.toString());
_setServerState(s, ServerConn.failed);
Loggers.app.warning('Get status from ${spi.name} failed', e);
// Update SSH session status to disconnected on status error
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
}
try {
// Parse script output into command-specific map
final parsedOutput = ScriptConstants.parseScriptOutput(raw);
final req = ServerStatusUpdateReq(
ss: sv.status,
parsedOutput: parsedOutput,
system: sv.status.system,
customCmds: spi.custom?.cmds ?? {},
);
sv.status = await Computer.shared.start(getStatus, req, taskName: 'StatusUpdateReq<${sv.id}>');
} catch (e, trace) {
TryLimiter.inc(sid);
sv.status.err = SSHErr(type: SSHErrType.getStatus, message: 'Parse failed: $e\n\n$raw');
_setServerState(s, ServerConn.failed);
Loggers.app.warning('Server status', e, trace);
// Update SSH session status to disconnected on parse error
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
}
/// Call this every time for setting [Server.isBusy] to false
_setServerState(s, ServerConn.finished);
// reset try times only after prepared successfully
TryLimiter.reset(sid);
}
}

View File

@@ -0,0 +1,278 @@
import 'dart:async';
import 'package:fl_lib/fl_lib.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:server_box/core/sync.dart';
import 'package:server_box/data/model/server/server.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/try_limiter.dart';
import 'package:server_box/data/provider/server/single.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/data/ssh/session_manager.dart';
part 'all.freezed.dart';
part 'all.g.dart';
@freezed
abstract class ServersState with _$ServersState {
const factory ServersState({
@Default({}) Map<String, Spi> servers,
@Default([]) List<String> serverOrder,
@Default(<String>{}) Set<String> tags,
@Default(<String>{}) Set<String> manualDisconnectedIds,
Timer? autoRefreshTimer,
}) = _ServersState;
}
@Riverpod(keepAlive: true)
class ServersNotifier extends _$ServersNotifier {
@override
ServersState build() {
return _load();
}
Future<void> reload() async {
final newState = _load();
if (newState == state) return;
state = newState;
await refresh();
}
ServersState _load() {
final spis = Stores.server.fetch();
final newServers = <String, Spi>{};
final newServerOrder = <String>[];
for (final spi in spis) {
newServers[spi.id] = spi;
}
final serverOrder_ = Stores.setting.serverOrder.fetch();
if (serverOrder_.isNotEmpty) {
spis.reorder(order: serverOrder_, finder: (n, id) => n.id == id);
newServerOrder.addAll(spis.map((e) => e.id));
} else {
newServerOrder.addAll(newServers.keys);
}
// Must use [equals] to compare [Order] here.
if (!newServerOrder.equals(serverOrder_)) {
Stores.setting.serverOrder.put(newServerOrder);
}
final newTags = _calculateTags(newServers);
return stateOrNull?.copyWith(servers: newServers, serverOrder: newServerOrder, tags: newTags) ??
ServersState(servers: newServers, serverOrder: newServerOrder, tags: newTags);
}
Set<String> _calculateTags(Map<String, Spi> servers) {
final tags = <String>{};
for (final spi in servers.values) {
final spiTags = spi.tags;
if (spiTags == null) continue;
for (final t in spiTags) {
tags.add(t);
}
}
return tags;
}
/// Get a [Spi] by [spi] or [id].
///
/// Priority: [spi] > [id]
Spi? pick({Spi? spi, String? id}) {
if (spi != null) {
return state.servers[spi.id];
}
if (id != null) {
return state.servers[id];
}
return null;
}
/// if [spi] is specificed then only refresh this server
/// [onlyFailed] only refresh failed servers
Future<void> refresh({Spi? spi, bool onlyFailed = false}) async {
if (spi != null) {
final newManualDisconnected = Set<String>.from(state.manualDisconnectedIds)..remove(spi.id);
state = state.copyWith(manualDisconnectedIds: newManualDisconnected);
final serverNotifier = ref.read(serverNotifierProvider(spi.id).notifier);
await serverNotifier.refresh();
return;
}
await Future.wait(
state.servers.entries.map((entry) async {
final serverId = entry.key;
final spi = entry.value;
if (onlyFailed) {
final serverState = ref.read(serverNotifierProvider(serverId));
if (serverState.conn != ServerConn.failed) return;
TryLimiter.reset(serverId);
}
if (state.manualDisconnectedIds.contains(serverId)) return;
final serverState = ref.read(serverNotifierProvider(serverId));
if (serverState.conn == ServerConn.disconnected && !spi.autoConnect) {
return;
}
final serverNotifier = ref.read(serverNotifierProvider(serverId).notifier);
await serverNotifier.refresh();
}),
);
}
Future<void> startAutoRefresh() async {
var duration = Stores.setting.serverStatusUpdateInterval.fetch();
stopAutoRefresh();
if (duration == 0) return;
if (duration < 0 || duration > 10 || duration == 1) {
duration = 3;
Loggers.app.warning('Invalid duration: $duration, use default 3');
}
final timer = Timer.periodic(Duration(seconds: duration), (_) async {
await refresh();
});
state = state.copyWith(autoRefreshTimer: timer);
}
void stopAutoRefresh() {
final timer = state.autoRefreshTimer;
if (timer != null) {
timer.cancel();
state = state.copyWith(autoRefreshTimer: null);
}
}
bool get isAutoRefreshOn => state.autoRefreshTimer != null;
void setDisconnected() {
for (final serverId in state.servers.keys) {
final serverNotifier = ref.read(serverNotifierProvider(serverId).notifier);
serverNotifier.updateConnection(ServerConn.disconnected);
// Update SSH session status to disconnected
final sessionId = 'ssh_$serverId';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
}
//TryLimiter.clear();
}
void closeServer({String? id}) {
if (id == null) {
for (final serverId in state.servers.keys) {
closeOneServer(serverId);
}
return;
}
closeOneServer(id);
}
void closeOneServer(String id) {
final spi = state.servers[id];
if (spi == null) {
Loggers.app.warning('Server with id $id not found');
return;
}
final serverNotifier = ref.read(serverNotifierProvider(id).notifier);
serverNotifier.closeConnection();
final newManualDisconnected = Set<String>.from(state.manualDisconnectedIds)..add(id);
state = state.copyWith(manualDisconnectedIds: newManualDisconnected);
// Remove SSH session when server is manually closed
final sessionId = 'ssh_$id';
TermSessionManager.remove(sessionId);
}
void addServer(Spi spi) {
final newServers = Map<String, Spi>.from(state.servers);
newServers[spi.id] = spi;
final newOrder = List<String>.from(state.serverOrder)..add(spi.id);
final newTags = _calculateTags(newServers);
state = state.copyWith(servers: newServers, serverOrder: newOrder, tags: newTags);
Stores.server.put(spi);
Stores.setting.serverOrder.put(newOrder);
refresh(spi: spi);
bakSync.sync(milliDelay: 1000);
}
void delServer(String id) {
final newServers = Map<String, Spi>.from(state.servers);
newServers.remove(id);
final newOrder = List<String>.from(state.serverOrder)..remove(id);
final newTags = _calculateTags(newServers);
state = state.copyWith(servers: newServers, serverOrder: newOrder, tags: newTags);
Stores.setting.serverOrder.put(newOrder);
Stores.server.delete(id);
// Remove SSH session when server is deleted
final sessionId = 'ssh_$id';
TermSessionManager.remove(sessionId);
bakSync.sync(milliDelay: 1000);
}
void deleteAll() {
// Remove all SSH sessions before clearing servers
for (final id in state.servers.keys) {
final sessionId = 'ssh_$id';
TermSessionManager.remove(sessionId);
}
state = const ServersState();
Stores.setting.serverOrder.put([]);
Stores.server.clear();
bakSync.sync(milliDelay: 1000);
}
Future<void> updateServer(Spi old, Spi newSpi) async {
if (old != newSpi) {
Stores.server.update(old, newSpi);
final newServers = Map<String, Spi>.from(state.servers);
final newOrder = List<String>.from(state.serverOrder);
if (newSpi.id != old.id) {
newServers[newSpi.id] = newSpi;
newServers.remove(old.id);
newOrder.update(old.id, newSpi.id);
Stores.setting.serverOrder.put(newOrder);
// Update SSH session ID when server ID changes
final oldSessionId = 'ssh_${old.id}';
TermSessionManager.remove(oldSessionId);
// Session will be re-added when reconnecting if necessary
} else {
newServers[old.id] = newSpi;
// Update SPI in the corresponding IndividualServerNotifier
final serverNotifier = ref.read(serverNotifierProvider(old.id).notifier);
serverNotifier.updateSpi(newSpi);
}
final newTags = _calculateTags(newServers);
state = state.copyWith(servers: newServers, serverOrder: newOrder, tags: newTags);
// Only reconnect if neccessary
if (newSpi.shouldReconnect(old)) {
// Use [newSpi.id] instead of [old.id] because [old.id] may be changed
TryLimiter.reset(newSpi.id);
refresh(spi: newSpi);
}
}
bakSync.sync(milliDelay: 1000);
}
}

View File

@@ -0,0 +1,307 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'all.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ServersState {
Map<String, Spi> get servers; List<String> get serverOrder; Set<String> get tags; Set<String> get manualDisconnectedIds; Timer? get autoRefreshTimer;
/// Create a copy of ServersState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ServersStateCopyWith<ServersState> get copyWith => _$ServersStateCopyWithImpl<ServersState>(this as ServersState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ServersState&&const DeepCollectionEquality().equals(other.servers, servers)&&const DeepCollectionEquality().equals(other.serverOrder, serverOrder)&&const DeepCollectionEquality().equals(other.tags, tags)&&const DeepCollectionEquality().equals(other.manualDisconnectedIds, manualDisconnectedIds)&&(identical(other.autoRefreshTimer, autoRefreshTimer) || other.autoRefreshTimer == autoRefreshTimer));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(servers),const DeepCollectionEquality().hash(serverOrder),const DeepCollectionEquality().hash(tags),const DeepCollectionEquality().hash(manualDisconnectedIds),autoRefreshTimer);
@override
String toString() {
return 'ServersState(servers: $servers, serverOrder: $serverOrder, tags: $tags, manualDisconnectedIds: $manualDisconnectedIds, autoRefreshTimer: $autoRefreshTimer)';
}
}
/// @nodoc
abstract mixin class $ServersStateCopyWith<$Res> {
factory $ServersStateCopyWith(ServersState value, $Res Function(ServersState) _then) = _$ServersStateCopyWithImpl;
@useResult
$Res call({
Map<String, Spi> servers, List<String> serverOrder, Set<String> tags, Set<String> manualDisconnectedIds, Timer? autoRefreshTimer
});
}
/// @nodoc
class _$ServersStateCopyWithImpl<$Res>
implements $ServersStateCopyWith<$Res> {
_$ServersStateCopyWithImpl(this._self, this._then);
final ServersState _self;
final $Res Function(ServersState) _then;
/// Create a copy of ServersState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? servers = null,Object? serverOrder = null,Object? tags = null,Object? manualDisconnectedIds = null,Object? autoRefreshTimer = freezed,}) {
return _then(_self.copyWith(
servers: null == servers ? _self.servers : servers // ignore: cast_nullable_to_non_nullable
as Map<String, Spi>,serverOrder: null == serverOrder ? _self.serverOrder : serverOrder // ignore: cast_nullable_to_non_nullable
as List<String>,tags: null == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable
as Set<String>,manualDisconnectedIds: null == manualDisconnectedIds ? _self.manualDisconnectedIds : manualDisconnectedIds // ignore: cast_nullable_to_non_nullable
as Set<String>,autoRefreshTimer: freezed == autoRefreshTimer ? _self.autoRefreshTimer : autoRefreshTimer // ignore: cast_nullable_to_non_nullable
as Timer?,
));
}
}
/// Adds pattern-matching-related methods to [ServersState].
extension ServersStatePatterns on ServersState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ServersState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ServersState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ServersState value) $default,){
final _that = this;
switch (_that) {
case _ServersState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ServersState value)? $default,){
final _that = this;
switch (_that) {
case _ServersState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( Map<String, Spi> servers, List<String> serverOrder, Set<String> tags, Set<String> manualDisconnectedIds, Timer? autoRefreshTimer)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ServersState() when $default != null:
return $default(_that.servers,_that.serverOrder,_that.tags,_that.manualDisconnectedIds,_that.autoRefreshTimer);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( Map<String, Spi> servers, List<String> serverOrder, Set<String> tags, Set<String> manualDisconnectedIds, Timer? autoRefreshTimer) $default,) {final _that = this;
switch (_that) {
case _ServersState():
return $default(_that.servers,_that.serverOrder,_that.tags,_that.manualDisconnectedIds,_that.autoRefreshTimer);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( Map<String, Spi> servers, List<String> serverOrder, Set<String> tags, Set<String> manualDisconnectedIds, Timer? autoRefreshTimer)? $default,) {final _that = this;
switch (_that) {
case _ServersState() when $default != null:
return $default(_that.servers,_that.serverOrder,_that.tags,_that.manualDisconnectedIds,_that.autoRefreshTimer);case _:
return null;
}
}
}
/// @nodoc
class _ServersState implements ServersState {
const _ServersState({final Map<String, Spi> servers = const {}, final List<String> serverOrder = const [], final Set<String> tags = const <String>{}, final Set<String> manualDisconnectedIds = const <String>{}, this.autoRefreshTimer}): _servers = servers,_serverOrder = serverOrder,_tags = tags,_manualDisconnectedIds = manualDisconnectedIds;
final Map<String, Spi> _servers;
@override@JsonKey() Map<String, Spi> get servers {
if (_servers is EqualUnmodifiableMapView) return _servers;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_servers);
}
final List<String> _serverOrder;
@override@JsonKey() List<String> get serverOrder {
if (_serverOrder is EqualUnmodifiableListView) return _serverOrder;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_serverOrder);
}
final Set<String> _tags;
@override@JsonKey() Set<String> get tags {
if (_tags is EqualUnmodifiableSetView) return _tags;
// ignore: implicit_dynamic_type
return EqualUnmodifiableSetView(_tags);
}
final Set<String> _manualDisconnectedIds;
@override@JsonKey() Set<String> get manualDisconnectedIds {
if (_manualDisconnectedIds is EqualUnmodifiableSetView) return _manualDisconnectedIds;
// ignore: implicit_dynamic_type
return EqualUnmodifiableSetView(_manualDisconnectedIds);
}
@override final Timer? autoRefreshTimer;
/// Create a copy of ServersState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ServersStateCopyWith<_ServersState> get copyWith => __$ServersStateCopyWithImpl<_ServersState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ServersState&&const DeepCollectionEquality().equals(other._servers, _servers)&&const DeepCollectionEquality().equals(other._serverOrder, _serverOrder)&&const DeepCollectionEquality().equals(other._tags, _tags)&&const DeepCollectionEquality().equals(other._manualDisconnectedIds, _manualDisconnectedIds)&&(identical(other.autoRefreshTimer, autoRefreshTimer) || other.autoRefreshTimer == autoRefreshTimer));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_servers),const DeepCollectionEquality().hash(_serverOrder),const DeepCollectionEquality().hash(_tags),const DeepCollectionEquality().hash(_manualDisconnectedIds),autoRefreshTimer);
@override
String toString() {
return 'ServersState(servers: $servers, serverOrder: $serverOrder, tags: $tags, manualDisconnectedIds: $manualDisconnectedIds, autoRefreshTimer: $autoRefreshTimer)';
}
}
/// @nodoc
abstract mixin class _$ServersStateCopyWith<$Res> implements $ServersStateCopyWith<$Res> {
factory _$ServersStateCopyWith(_ServersState value, $Res Function(_ServersState) _then) = __$ServersStateCopyWithImpl;
@override @useResult
$Res call({
Map<String, Spi> servers, List<String> serverOrder, Set<String> tags, Set<String> manualDisconnectedIds, Timer? autoRefreshTimer
});
}
/// @nodoc
class __$ServersStateCopyWithImpl<$Res>
implements _$ServersStateCopyWith<$Res> {
__$ServersStateCopyWithImpl(this._self, this._then);
final _ServersState _self;
final $Res Function(_ServersState) _then;
/// Create a copy of ServersState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? servers = null,Object? serverOrder = null,Object? tags = null,Object? manualDisconnectedIds = null,Object? autoRefreshTimer = freezed,}) {
return _then(_ServersState(
servers: null == servers ? _self._servers : servers // ignore: cast_nullable_to_non_nullable
as Map<String, Spi>,serverOrder: null == serverOrder ? _self._serverOrder : serverOrder // ignore: cast_nullable_to_non_nullable
as List<String>,tags: null == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable
as Set<String>,manualDisconnectedIds: null == manualDisconnectedIds ? _self._manualDisconnectedIds : manualDisconnectedIds // ignore: cast_nullable_to_non_nullable
as Set<String>,autoRefreshTimer: freezed == autoRefreshTimer ? _self.autoRefreshTimer : autoRefreshTimer // ignore: cast_nullable_to_non_nullable
as Timer?,
));
}
}
// dart format on

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'all.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$serversNotifierHash() => r'2ae641188f772794a32e8700c008f51ba0cc1ec9';
/// See also [ServersNotifier].
@ProviderFor(ServersNotifier)
final serversNotifierProvider =
NotifierProvider<ServersNotifier, ServersState>.internal(
ServersNotifier.new,
name: r'serversNotifierProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$serversNotifierHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ServersNotifier = Notifier<ServersState>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -0,0 +1,361 @@
import 'dart:async';
import 'dart:convert';
import 'package:computer/computer.dart';
import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter_gbk2utf8/flutter_gbk2utf8.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:server_box/core/extension/ssh_client.dart';
import 'package:server_box/core/utils/server.dart';
import 'package:server_box/core/utils/ssh_auth.dart';
import 'package:server_box/data/helper/system_detector.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/app/scripts/script_consts.dart';
import 'package:server_box/data/model/app/scripts/shell_func.dart';
import 'package:server_box/data/model/server/server.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/server_status_update_req.dart';
import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/model/server/try_limiter.dart';
import 'package:server_box/data/provider/server/all.dart';
import 'package:server_box/data/res/status.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/data/ssh/session_manager.dart';
part 'single.g.dart';
part 'single.freezed.dart';
// Individual server state, including connection and status information
@freezed
abstract class ServerState with _$ServerState {
const factory ServerState({
required Spi spi,
required ServerStatus status,
@Default(ServerConn.disconnected) ServerConn conn,
SSHClient? client,
Future<void>? updateFuture,
}) = _ServerState;
}
// Individual server state management
@riverpod
class ServerNotifier extends _$ServerNotifier {
@override
ServerState build(String serverId) {
final serverNotifier = ref.read(serversNotifierProvider);
final spi = serverNotifier.servers[serverId];
if (spi == null) {
throw StateError('Server $serverId not found');
}
return ServerState(spi: spi, status: InitStatus.status);
}
// Update connection status
void updateConnection(ServerConn conn) {
state = state.copyWith(conn: conn);
}
// Update server status
void updateStatus(ServerStatus status) {
state = state.copyWith(status: status);
}
// Update SSH client
void updateClient(SSHClient? client) {
state = state.copyWith(client: client);
}
// Update SPI configuration
void updateSpi(Spi spi) {
state = state.copyWith(spi: spi);
}
// Close connection
void closeConnection() {
final client = state.client;
client?.close();
state = state.copyWith(client: null, conn: ServerConn.disconnected);
}
// Refresh server status
Future<void> refresh() async {
if (state.updateFuture != null) {
await state.updateFuture;
return;
}
final updateFuture = _updateServer();
state = state.copyWith(updateFuture: updateFuture);
try {
await updateFuture;
} finally {
state = state.copyWith(updateFuture: null);
}
}
Future<void> _updateServer() async {
await _getData();
}
Future<void> _getData() async {
final spi = state.spi;
final sid = spi.id;
if (!TryLimiter.canTry(sid)) {
if (state.conn != ServerConn.failed) {
updateConnection(ServerConn.failed);
}
return;
}
final newStatus = state.status..err = null; // Clear previous error
updateStatus(newStatus);
if (state.conn < ServerConn.connecting || (state.client?.isClosed ?? true)) {
updateConnection(ServerConn.connecting);
// Wake on LAN
final wol = spi.wolCfg;
if (wol != null) {
try {
await wol.wake();
} catch (e) {
Loggers.app.warning('Wake on lan failed', e);
}
}
try {
final time1 = DateTime.now();
final client = await genClient(
spi,
timeout: Duration(seconds: Stores.setting.timeout.fetch()),
onKeyboardInteractive: (_) => KeybordInteractive.defaultHandle(spi),
);
updateClient(client);
final time2 = DateTime.now();
final spentTime = time2.difference(time1).inMilliseconds;
if (spi.jumpId == null) {
Loggers.app.info('Connected to ${spi.name} in $spentTime ms.');
} else {
Loggers.app.info('Jump to ${spi.name} in $spentTime ms.');
}
final sessionId = 'ssh_${spi.id}';
TermSessionManager.add(
id: sessionId,
spi: spi,
startTimeMs: time1.millisecondsSinceEpoch,
disconnect: () => ref.read(serversNotifierProvider.notifier).closeOneServer(spi.id),
status: TermSessionStatus.connecting,
);
TermSessionManager.setActive(sessionId, hasTerminal: false);
} catch (e) {
TryLimiter.inc(sid);
final newStatus = state.status..err = SSHErr(type: SSHErrType.connect, message: e.toString());
updateStatus(newStatus);
updateConnection(ServerConn.failed);
// Remove SSH session when connection fails
final sessionId = 'ssh_${spi.id}';
TermSessionManager.remove(sessionId);
Loggers.app.warning('Connect to ${spi.name} failed', e);
return;
}
updateConnection(ServerConn.connected);
// Update SSH session status to connected
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.connected);
try {
// Detect system type
final detectedSystemType = await SystemDetector.detect(state.client!, spi);
final newStatus = state.status..system = detectedSystemType;
updateStatus(newStatus);
final (_, writeScriptResult) = await state.client!.exec(
(session) async {
final scriptRaw = ShellFuncManager.allScript(
spi.custom?.cmds,
systemType: detectedSystemType,
disabledCmdTypes: spi.disabledCmdTypes,
).uint8List;
session.stdin.add(scriptRaw);
session.stdin.close();
},
entry: ShellFuncManager.getInstallShellCmd(
spi.id,
systemType: detectedSystemType,
customDir: spi.custom?.scriptDir,
),
);
if (writeScriptResult.isNotEmpty && detectedSystemType != SystemType.windows) {
ShellFuncManager.switchScriptDir(spi.id, systemType: detectedSystemType);
throw writeScriptResult;
}
} on SSHAuthAbortError catch (e) {
TryLimiter.inc(sid);
final err = SSHErr(type: SSHErrType.auth, message: e.toString());
final newStatus = state.status..err = err;
updateStatus(newStatus);
Loggers.app.warning(err);
updateConnection(ServerConn.failed);
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
} on SSHAuthFailError catch (e) {
TryLimiter.inc(sid);
final err = SSHErr(type: SSHErrType.auth, message: e.toString());
final newStatus = state.status..err = err;
updateStatus(newStatus);
Loggers.app.warning(err);
updateConnection(ServerConn.failed);
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
} catch (e) {
final err = SSHErr(type: SSHErrType.writeScript, message: e.toString());
final newStatus = state.status..err = err;
updateStatus(newStatus);
Loggers.app.warning(err);
updateConnection(ServerConn.failed);
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
}
}
if (state.conn == ServerConn.connecting) return;
// Keep finished status to prevent UI from refreshing to loading state
if (state.conn != ServerConn.finished) {
updateConnection(ServerConn.loading);
}
List<String>? segments;
String? raw;
try {
final execResult = await state.client?.run(
ShellFunc.status.exec(spi.id, systemType: state.status.system, customDir: spi.custom?.scriptDir),
);
if (execResult != null) {
String? rawStr;
bool needGbk = false;
try {
rawStr = utf8.decode(execResult, allowMalformed: true);
// If there are unparseable characters, try fallback to GBK decoding
if (rawStr.contains('<EFBFBD>')) {
Loggers.app.warning('UTF8 decoding failed, use GBK decoding');
needGbk = true;
}
} catch (e) {
Loggers.app.warning('UTF8 decoding failed, use GBK decoding', e);
needGbk = true;
}
if (needGbk) {
try {
rawStr = gbk.decode(execResult);
} catch (e2) {
Loggers.app.warning('GBK decoding failed', e2);
rawStr = null;
}
}
if (rawStr == null) {
Loggers.app.warning('Decoding failed, execResult: $execResult');
}
raw = rawStr;
} else {
raw = execResult.toString();
}
if (raw == null || raw.isEmpty) {
TryLimiter.inc(sid);
final newStatus = state.status
..err = SSHErr(type: SSHErrType.segements, message: 'decode or split failed, raw:\n$raw');
updateStatus(newStatus);
updateConnection(ServerConn.failed);
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
}
segments = raw.split(ScriptConstants.separator).map((e) => e.trim()).toList();
if (raw.isEmpty || segments.isEmpty) {
if (Stores.setting.keepStatusWhenErr.fetch()) {
// Keep previous server status when error occurs
if (state.conn != ServerConn.failed && state.status.more.isNotEmpty) {
return;
}
}
TryLimiter.inc(sid);
final newStatus = state.status
..err = SSHErr(type: SSHErrType.segements, message: 'Seperate segments failed, raw:\n$raw');
updateStatus(newStatus);
updateConnection(ServerConn.failed);
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
}
} catch (e) {
TryLimiter.inc(sid);
final newStatus = state.status..err = SSHErr(type: SSHErrType.getStatus, message: e.toString());
updateStatus(newStatus);
updateConnection(ServerConn.failed);
Loggers.app.warning('Get status from ${spi.name} failed', e);
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
}
try {
// Parse script output into command-specific mappings
final parsedOutput = ScriptConstants.parseScriptOutput(raw);
final req = ServerStatusUpdateReq(
ss: state.status,
parsedOutput: parsedOutput,
system: state.status.system,
customCmds: spi.custom?.cmds ?? {},
);
final newStatus = await Computer.shared.start(getStatus, req, taskName: 'StatusUpdateReq<${spi.id}>');
updateStatus(newStatus);
} catch (e, trace) {
TryLimiter.inc(sid);
final newStatus = state.status
..err = SSHErr(type: SSHErrType.getStatus, message: 'Parse failed: $e\n\n$raw');
updateStatus(newStatus);
updateConnection(ServerConn.failed);
Loggers.app.warning('Server status', e, trace);
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
}
// Set Server.isBusy to false each time this method is called
updateConnection(ServerConn.finished);
// Reset retry count only after successful preparation
TryLimiter.reset(sid);
}
}
extension IndividualServerStateExtension on ServerState {
bool get needGenClient => conn < ServerConn.connecting;
bool get canViewDetails => conn == ServerConn.finished;
String get id => spi.id;
}

View File

@@ -0,0 +1,301 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'single.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ServerState {
Spi get spi; ServerStatus get status; ServerConn get conn; SSHClient? get client; Future<void>? get updateFuture;
/// Create a copy of ServerState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ServerStateCopyWith<ServerState> get copyWith => _$ServerStateCopyWithImpl<ServerState>(this as ServerState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ServerState&&(identical(other.spi, spi) || other.spi == spi)&&(identical(other.status, status) || other.status == status)&&(identical(other.conn, conn) || other.conn == conn)&&(identical(other.client, client) || other.client == client)&&(identical(other.updateFuture, updateFuture) || other.updateFuture == updateFuture));
}
@override
int get hashCode => Object.hash(runtimeType,spi,status,conn,client,updateFuture);
@override
String toString() {
return 'ServerState(spi: $spi, status: $status, conn: $conn, client: $client, updateFuture: $updateFuture)';
}
}
/// @nodoc
abstract mixin class $ServerStateCopyWith<$Res> {
factory $ServerStateCopyWith(ServerState value, $Res Function(ServerState) _then) = _$ServerStateCopyWithImpl;
@useResult
$Res call({
Spi spi, ServerStatus status, ServerConn conn, SSHClient? client, Future<void>? updateFuture
});
$SpiCopyWith<$Res> get spi;
}
/// @nodoc
class _$ServerStateCopyWithImpl<$Res>
implements $ServerStateCopyWith<$Res> {
_$ServerStateCopyWithImpl(this._self, this._then);
final ServerState _self;
final $Res Function(ServerState) _then;
/// Create a copy of ServerState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? spi = null,Object? status = null,Object? conn = null,Object? client = freezed,Object? updateFuture = freezed,}) {
return _then(_self.copyWith(
spi: null == spi ? _self.spi : spi // ignore: cast_nullable_to_non_nullable
as Spi,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as ServerStatus,conn: null == conn ? _self.conn : conn // ignore: cast_nullable_to_non_nullable
as ServerConn,client: freezed == client ? _self.client : client // ignore: cast_nullable_to_non_nullable
as SSHClient?,updateFuture: freezed == updateFuture ? _self.updateFuture : updateFuture // ignore: cast_nullable_to_non_nullable
as Future<void>?,
));
}
/// Create a copy of ServerState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SpiCopyWith<$Res> get spi {
return $SpiCopyWith<$Res>(_self.spi, (value) {
return _then(_self.copyWith(spi: value));
});
}
}
/// Adds pattern-matching-related methods to [ServerState].
extension ServerStatePatterns on ServerState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ServerState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ServerState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ServerState value) $default,){
final _that = this;
switch (_that) {
case _ServerState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ServerState value)? $default,){
final _that = this;
switch (_that) {
case _ServerState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( Spi spi, ServerStatus status, ServerConn conn, SSHClient? client, Future<void>? updateFuture)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ServerState() when $default != null:
return $default(_that.spi,_that.status,_that.conn,_that.client,_that.updateFuture);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( Spi spi, ServerStatus status, ServerConn conn, SSHClient? client, Future<void>? updateFuture) $default,) {final _that = this;
switch (_that) {
case _ServerState():
return $default(_that.spi,_that.status,_that.conn,_that.client,_that.updateFuture);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( Spi spi, ServerStatus status, ServerConn conn, SSHClient? client, Future<void>? updateFuture)? $default,) {final _that = this;
switch (_that) {
case _ServerState() when $default != null:
return $default(_that.spi,_that.status,_that.conn,_that.client,_that.updateFuture);case _:
return null;
}
}
}
/// @nodoc
class _ServerState implements ServerState {
const _ServerState({required this.spi, required this.status, this.conn = ServerConn.disconnected, this.client, this.updateFuture});
@override final Spi spi;
@override final ServerStatus status;
@override@JsonKey() final ServerConn conn;
@override final SSHClient? client;
@override final Future<void>? updateFuture;
/// Create a copy of ServerState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ServerStateCopyWith<_ServerState> get copyWith => __$ServerStateCopyWithImpl<_ServerState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ServerState&&(identical(other.spi, spi) || other.spi == spi)&&(identical(other.status, status) || other.status == status)&&(identical(other.conn, conn) || other.conn == conn)&&(identical(other.client, client) || other.client == client)&&(identical(other.updateFuture, updateFuture) || other.updateFuture == updateFuture));
}
@override
int get hashCode => Object.hash(runtimeType,spi,status,conn,client,updateFuture);
@override
String toString() {
return 'ServerState(spi: $spi, status: $status, conn: $conn, client: $client, updateFuture: $updateFuture)';
}
}
/// @nodoc
abstract mixin class _$ServerStateCopyWith<$Res> implements $ServerStateCopyWith<$Res> {
factory _$ServerStateCopyWith(_ServerState value, $Res Function(_ServerState) _then) = __$ServerStateCopyWithImpl;
@override @useResult
$Res call({
Spi spi, ServerStatus status, ServerConn conn, SSHClient? client, Future<void>? updateFuture
});
@override $SpiCopyWith<$Res> get spi;
}
/// @nodoc
class __$ServerStateCopyWithImpl<$Res>
implements _$ServerStateCopyWith<$Res> {
__$ServerStateCopyWithImpl(this._self, this._then);
final _ServerState _self;
final $Res Function(_ServerState) _then;
/// Create a copy of ServerState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? spi = null,Object? status = null,Object? conn = null,Object? client = freezed,Object? updateFuture = freezed,}) {
return _then(_ServerState(
spi: null == spi ? _self.spi : spi // ignore: cast_nullable_to_non_nullable
as Spi,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as ServerStatus,conn: null == conn ? _self.conn : conn // ignore: cast_nullable_to_non_nullable
as ServerConn,client: freezed == client ? _self.client : client // ignore: cast_nullable_to_non_nullable
as SSHClient?,updateFuture: freezed == updateFuture ? _self.updateFuture : updateFuture // ignore: cast_nullable_to_non_nullable
as Future<void>?,
));
}
/// Create a copy of ServerState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SpiCopyWith<$Res> get spi {
return $SpiCopyWith<$Res>(_self.spi, (value) {
return _then(_self.copyWith(spi: value));
});
}
}
// dart format on

View File

@@ -0,0 +1,163 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'single.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$serverNotifierHash() => r'5625b0a4762c28efdbc124809c03b84a51d213b1';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
abstract class _$ServerNotifier
extends BuildlessAutoDisposeNotifier<ServerState> {
late final String serverId;
ServerState build(String serverId);
}
/// See also [ServerNotifier].
@ProviderFor(ServerNotifier)
const serverNotifierProvider = ServerNotifierFamily();
/// See also [ServerNotifier].
class ServerNotifierFamily extends Family<ServerState> {
/// See also [ServerNotifier].
const ServerNotifierFamily();
/// See also [ServerNotifier].
ServerNotifierProvider call(String serverId) {
return ServerNotifierProvider(serverId);
}
@override
ServerNotifierProvider getProviderOverride(
covariant ServerNotifierProvider provider,
) {
return call(provider.serverId);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'serverNotifierProvider';
}
/// See also [ServerNotifier].
class ServerNotifierProvider
extends AutoDisposeNotifierProviderImpl<ServerNotifier, ServerState> {
/// See also [ServerNotifier].
ServerNotifierProvider(String serverId)
: this._internal(
() => ServerNotifier()..serverId = serverId,
from: serverNotifierProvider,
name: r'serverNotifierProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$serverNotifierHash,
dependencies: ServerNotifierFamily._dependencies,
allTransitiveDependencies:
ServerNotifierFamily._allTransitiveDependencies,
serverId: serverId,
);
ServerNotifierProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.serverId,
}) : super.internal();
final String serverId;
@override
ServerState runNotifierBuild(covariant ServerNotifier notifier) {
return notifier.build(serverId);
}
@override
Override overrideWith(ServerNotifier Function() create) {
return ProviderOverride(
origin: this,
override: ServerNotifierProvider._internal(
() => create()..serverId = serverId,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
serverId: serverId,
),
);
}
@override
AutoDisposeNotifierProviderElement<ServerNotifier, ServerState>
createElement() {
return _ServerNotifierProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is ServerNotifierProvider && other.serverId == serverId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, serverId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin ServerNotifierRef on AutoDisposeNotifierProviderRef<ServerState> {
/// The parameter `serverId` of this provider.
String get serverId;
}
class _ServerNotifierProviderElement
extends AutoDisposeNotifierProviderElement<ServerNotifier, ServerState>
with ServerNotifierRef {
_ServerNotifierProviderElement(super.provider);
@override
String get serverId => (origin as ServerNotifierProvider).serverId;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -1,41 +1,69 @@
import 'dart:async';
import 'package:fl_lib/fl_lib.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:server_box/data/model/sftp/worker.dart';
class SftpProvider extends Provider {
const SftpProvider._();
static const instance = SftpProvider._();
part 'sftp.freezed.dart';
part 'sftp.g.dart';
static final status = <SftpReqStatus>[].vn;
@freezed
abstract class SftpState with _$SftpState {
const factory SftpState({
@Default(<SftpReqStatus>[]) List<SftpReqStatus> requests,
}) = _SftpState;
}
static SftpReqStatus? get(int id) {
return status.value.singleWhere((element) => element.id == id);
@Riverpod(keepAlive: true)
class SftpNotifier extends _$SftpNotifier {
@override
SftpState build() {
return const SftpState();
}
static int add(SftpReq req, {Completer? completer}) {
final reqStat = SftpReqStatus(notifyListeners: status.notify, completer: completer, req: req);
status.value.add(reqStat);
status.notify();
SftpReqStatus? get(int id) {
try {
return state.requests.singleWhere((element) => element.id == id);
} catch (e) {
return null;
}
}
int add(SftpReq req, {Completer? completer}) {
final reqStat = SftpReqStatus(
notifyListeners: _notifyListeners,
completer: completer,
req: req,
);
state = state.copyWith(
requests: [...state.requests, reqStat],
);
return reqStat.id;
}
static void dispose() {
for (final item in status.value) {
void dispose() {
for (final item in state.requests) {
item.dispose();
}
status.value.clear();
status.notify();
state = state.copyWith(requests: []);
}
static void cancel(int id) {
final idx = status.value.indexWhere((e) => e.id == id);
if (idx < 0 || idx >= status.value.length) {
void cancel(int id) {
final idx = state.requests.indexWhere((e) => e.id == id);
if (idx < 0 || idx >= state.requests.length) {
dprint('SftpProvider.cancel: id $id not found');
return;
}
status.value[idx].dispose();
status.value.removeAt(idx);
status.notify();
final item = state.requests[idx];
item.dispose();
final newRequests = List<SftpReqStatus>.from(state.requests)
..removeAt(idx);
state = state.copyWith(requests: newRequests);
}
void _notifyListeners() {
// Force state update to notify listeners
state = state.copyWith();
}
}

View File

@@ -0,0 +1,277 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'sftp.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SftpState {
List<SftpReqStatus> get requests;
/// Create a copy of SftpState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SftpStateCopyWith<SftpState> get copyWith => _$SftpStateCopyWithImpl<SftpState>(this as SftpState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SftpState&&const DeepCollectionEquality().equals(other.requests, requests));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(requests));
@override
String toString() {
return 'SftpState(requests: $requests)';
}
}
/// @nodoc
abstract mixin class $SftpStateCopyWith<$Res> {
factory $SftpStateCopyWith(SftpState value, $Res Function(SftpState) _then) = _$SftpStateCopyWithImpl;
@useResult
$Res call({
List<SftpReqStatus> requests
});
}
/// @nodoc
class _$SftpStateCopyWithImpl<$Res>
implements $SftpStateCopyWith<$Res> {
_$SftpStateCopyWithImpl(this._self, this._then);
final SftpState _self;
final $Res Function(SftpState) _then;
/// Create a copy of SftpState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? requests = null,}) {
return _then(_self.copyWith(
requests: null == requests ? _self.requests : requests // ignore: cast_nullable_to_non_nullable
as List<SftpReqStatus>,
));
}
}
/// Adds pattern-matching-related methods to [SftpState].
extension SftpStatePatterns on SftpState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SftpState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SftpState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SftpState value) $default,){
final _that = this;
switch (_that) {
case _SftpState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SftpState value)? $default,){
final _that = this;
switch (_that) {
case _SftpState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( List<SftpReqStatus> requests)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SftpState() when $default != null:
return $default(_that.requests);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<SftpReqStatus> requests) $default,) {final _that = this;
switch (_that) {
case _SftpState():
return $default(_that.requests);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<SftpReqStatus> requests)? $default,) {final _that = this;
switch (_that) {
case _SftpState() when $default != null:
return $default(_that.requests);case _:
return null;
}
}
}
/// @nodoc
class _SftpState implements SftpState {
const _SftpState({final List<SftpReqStatus> requests = const <SftpReqStatus>[]}): _requests = requests;
final List<SftpReqStatus> _requests;
@override@JsonKey() List<SftpReqStatus> get requests {
if (_requests is EqualUnmodifiableListView) return _requests;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_requests);
}
/// Create a copy of SftpState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SftpStateCopyWith<_SftpState> get copyWith => __$SftpStateCopyWithImpl<_SftpState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SftpState&&const DeepCollectionEquality().equals(other._requests, _requests));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_requests));
@override
String toString() {
return 'SftpState(requests: $requests)';
}
}
/// @nodoc
abstract mixin class _$SftpStateCopyWith<$Res> implements $SftpStateCopyWith<$Res> {
factory _$SftpStateCopyWith(_SftpState value, $Res Function(_SftpState) _then) = __$SftpStateCopyWithImpl;
@override @useResult
$Res call({
List<SftpReqStatus> requests
});
}
/// @nodoc
class __$SftpStateCopyWithImpl<$Res>
implements _$SftpStateCopyWith<$Res> {
__$SftpStateCopyWithImpl(this._self, this._then);
final _SftpState _self;
final $Res Function(_SftpState) _then;
/// Create a copy of SftpState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? requests = null,}) {
return _then(_SftpState(
requests: null == requests ? _self._requests : requests // ignore: cast_nullable_to_non_nullable
as List<SftpReqStatus>,
));
}
}
// dart format on

View File

@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sftp.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$sftpNotifierHash() => r'f8412a4bd1f2bc5919ec31a3eba1c27e9a578f41';
/// See also [SftpNotifier].
@ProviderFor(SftpNotifier)
final sftpNotifierProvider = NotifierProvider<SftpNotifier, SftpState>.internal(
SftpNotifier.new,
name: r'sftpNotifierProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$sftpNotifierHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$SftpNotifier = Notifier<SftpState>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -1,80 +1,105 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.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';
class SnippetProvider extends Provider {
const SnippetProvider._();
static const instance = SnippetProvider._();
part 'snippet.freezed.dart';
part 'snippet.g.dart';
static final snippets = <Snippet>[].vn;
static final tags = <String>{}.vn;
@freezed
abstract class SnippetState with _$SnippetState {
const factory SnippetState({
@Default(<Snippet>[]) List<Snippet> snippets,
@Default(<String>{}) Set<String> tags,
}) = _SnippetState;
}
@Riverpod(keepAlive: true)
class SnippetNotifier extends _$SnippetNotifier {
@override
void load() {
super.load();
final snippets_ = Stores.snippet.fetch();
SnippetState build() {
return _load();
}
void reload() {
final newState = _load();
if (newState == state) return;
state = newState;
}
SnippetState _load() {
final snippets = Stores.snippet.fetch();
final order = Stores.setting.snippetOrder.fetch();
List<Snippet> orderedSnippets = snippets;
if (order.isNotEmpty) {
final surplus = snippets_.reorder(
order: order,
finder: (n, name) => n.name == name,
);
final surplus = snippets.reorder(order: order, finder: (n, name) => n.name == name);
order.removeWhere((e) => surplus.any((ele) => ele == e));
if (order != Stores.setting.snippetOrder.fetch()) {
Stores.setting.snippetOrder.put(order);
}
orderedSnippets = snippets;
}
snippets.value = snippets_;
_updateTags();
final newTags = _computeTags(orderedSnippets);
return stateOrNull?.copyWith(snippets: orderedSnippets, tags: newTags) ??
SnippetState(snippets: orderedSnippets, tags: newTags);
}
static void _updateTags() {
final tags_ = <String>{};
for (final s in snippets.value) {
Set<String> _computeTags(List<Snippet> snippets) {
final tags = <String>{};
for (final s in snippets) {
final t = s.tags;
if (t != null) {
tags_.addAll(t);
tags.addAll(t);
}
}
tags.value = tags_;
return tags;
}
static void add(Snippet snippet) {
snippets.value.add(snippet);
snippets.notify();
void add(Snippet snippet) {
final newSnippets = [...state.snippets, snippet];
final newTags = _computeTags(newSnippets);
state = state.copyWith(snippets: newSnippets, tags: newTags);
Stores.snippet.put(snippet);
_updateTags();
bakSync.sync(milliDelay: 1000);
}
static void del(Snippet snippet) {
snippets.value.remove(snippet);
snippets.notify();
void del(Snippet snippet) {
final newSnippets = state.snippets.where((s) => s != snippet).toList();
final newTags = _computeTags(newSnippets);
state = state.copyWith(snippets: newSnippets, tags: newTags);
Stores.snippet.delete(snippet);
_updateTags();
bakSync.sync(milliDelay: 1000);
}
static void update(Snippet old, Snippet newOne) {
snippets.value.remove(old);
snippets.value.add(newOne);
snippets.notify();
void update(Snippet old, Snippet newOne) {
final newSnippets = state.snippets.map((s) => s == old ? newOne : s).toList();
final newTags = _computeTags(newSnippets);
state = state.copyWith(snippets: newSnippets, tags: newTags);
Stores.snippet.delete(old);
Stores.snippet.put(newOne);
_updateTags();
bakSync.sync(milliDelay: 1000);
}
static void renameTag(String old, String newOne) {
for (final s in snippets.value) {
void renameTag(String old, String newOne) {
final updatedSnippets = <Snippet>[];
for (final s in state.snippets) {
if (s.tags?.contains(old) ?? false) {
s.tags?.remove(old);
s.tags?.add(newOne);
Stores.snippet.put(s);
final newTags = Set<String>.from(s.tags!);
newTags.remove(old);
newTags.add(newOne);
final updatedSnippet = s.copyWith(tags: newTags.toList());
updatedSnippets.add(updatedSnippet);
Stores.snippet.put(updatedSnippet);
} else {
updatedSnippets.add(s);
}
}
_updateTags();
final newTags = _computeTags(updatedSnippets);
state = state.copyWith(snippets: updatedSnippets, tags: newTags);
bakSync.sync(milliDelay: 1000);
}
}

View File

@@ -0,0 +1,286 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'snippet.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SnippetState {
List<Snippet> get snippets; Set<String> get tags;
/// Create a copy of SnippetState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnippetStateCopyWith<SnippetState> get copyWith => _$SnippetStateCopyWithImpl<SnippetState>(this as SnippetState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnippetState&&const DeepCollectionEquality().equals(other.snippets, snippets)&&const DeepCollectionEquality().equals(other.tags, tags));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(snippets),const DeepCollectionEquality().hash(tags));
@override
String toString() {
return 'SnippetState(snippets: $snippets, tags: $tags)';
}
}
/// @nodoc
abstract mixin class $SnippetStateCopyWith<$Res> {
factory $SnippetStateCopyWith(SnippetState value, $Res Function(SnippetState) _then) = _$SnippetStateCopyWithImpl;
@useResult
$Res call({
List<Snippet> snippets, Set<String> tags
});
}
/// @nodoc
class _$SnippetStateCopyWithImpl<$Res>
implements $SnippetStateCopyWith<$Res> {
_$SnippetStateCopyWithImpl(this._self, this._then);
final SnippetState _self;
final $Res Function(SnippetState) _then;
/// Create a copy of SnippetState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? snippets = null,Object? tags = null,}) {
return _then(_self.copyWith(
snippets: null == snippets ? _self.snippets : snippets // ignore: cast_nullable_to_non_nullable
as List<Snippet>,tags: null == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable
as Set<String>,
));
}
}
/// Adds pattern-matching-related methods to [SnippetState].
extension SnippetStatePatterns on SnippetState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SnippetState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SnippetState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SnippetState value) $default,){
final _that = this;
switch (_that) {
case _SnippetState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SnippetState value)? $default,){
final _that = this;
switch (_that) {
case _SnippetState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( List<Snippet> snippets, Set<String> tags)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnippetState() when $default != null:
return $default(_that.snippets,_that.tags);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<Snippet> snippets, Set<String> tags) $default,) {final _that = this;
switch (_that) {
case _SnippetState():
return $default(_that.snippets,_that.tags);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<Snippet> snippets, Set<String> tags)? $default,) {final _that = this;
switch (_that) {
case _SnippetState() when $default != null:
return $default(_that.snippets,_that.tags);case _:
return null;
}
}
}
/// @nodoc
class _SnippetState implements SnippetState {
const _SnippetState({final List<Snippet> snippets = const <Snippet>[], final Set<String> tags = const <String>{}}): _snippets = snippets,_tags = tags;
final List<Snippet> _snippets;
@override@JsonKey() List<Snippet> get snippets {
if (_snippets is EqualUnmodifiableListView) return _snippets;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_snippets);
}
final Set<String> _tags;
@override@JsonKey() Set<String> get tags {
if (_tags is EqualUnmodifiableSetView) return _tags;
// ignore: implicit_dynamic_type
return EqualUnmodifiableSetView(_tags);
}
/// Create a copy of SnippetState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnippetStateCopyWith<_SnippetState> get copyWith => __$SnippetStateCopyWithImpl<_SnippetState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnippetState&&const DeepCollectionEquality().equals(other._snippets, _snippets)&&const DeepCollectionEquality().equals(other._tags, _tags));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_snippets),const DeepCollectionEquality().hash(_tags));
@override
String toString() {
return 'SnippetState(snippets: $snippets, tags: $tags)';
}
}
/// @nodoc
abstract mixin class _$SnippetStateCopyWith<$Res> implements $SnippetStateCopyWith<$Res> {
factory _$SnippetStateCopyWith(_SnippetState value, $Res Function(_SnippetState) _then) = __$SnippetStateCopyWithImpl;
@override @useResult
$Res call({
List<Snippet> snippets, Set<String> tags
});
}
/// @nodoc
class __$SnippetStateCopyWithImpl<$Res>
implements _$SnippetStateCopyWith<$Res> {
__$SnippetStateCopyWithImpl(this._self, this._then);
final _SnippetState _self;
final $Res Function(_SnippetState) _then;
/// Create a copy of SnippetState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? snippets = null,Object? tags = null,}) {
return _then(_SnippetState(
snippets: null == snippets ? _self._snippets : snippets // ignore: cast_nullable_to_non_nullable
as List<Snippet>,tags: null == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable
as Set<String>,
));
}
}
// dart format on

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'snippet.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$snippetNotifierHash() => r'caf0361f9a0346fb99cb90f032f1ceb29446dd71';
/// See also [SnippetNotifier].
@ProviderFor(SnippetNotifier)
final snippetNotifierProvider =
NotifierProvider<SnippetNotifier, SnippetState>.internal(
SnippetNotifier.new,
name: r'snippetNotifierProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$snippetNotifierHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$SnippetNotifier = Notifier<SnippetState>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -1,45 +1,57 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:server_box/core/extension/ssh_client.dart';
import 'package:server_box/data/model/app/scripts/script_consts.dart';
import 'package:server_box/data/model/server/server.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/systemd.dart';
import 'package:server_box/data/provider/server.dart';
import 'package:server_box/data/provider/server/single.dart';
final class SystemdProvider {
late final VNode<Server> _si;
part 'systemd.freezed.dart';
part 'systemd.g.dart';
SystemdProvider.init(Spi spi) {
_si = ServerProvider.pick(spi: spi)!;
getUnits();
}
@freezed
abstract class SystemdState with _$SystemdState {
const factory SystemdState({
@Default(false) bool isBusy,
@Default(<SystemdUnit>[]) List<SystemdUnit> units,
@Default(SystemdScopeFilter.all) SystemdScopeFilter scopeFilter,
}) = _SystemdState;
}
final isBusy = false.vn;
final units = <SystemdUnit>[].vn;
final scopeFilter = SystemdScopeFilter.all.vn;
@riverpod
class SystemdNotifier extends _$SystemdNotifier {
late final ServerState _si;
void dispose() {
isBusy.dispose();
units.dispose();
scopeFilter.dispose();
@override
SystemdState build(Spi spi) {
final si = ref.read(serverNotifierProvider(spi.id));
_si = si;
// Async initialization
Future.microtask(() => getUnits());
return const SystemdState();
}
List<SystemdUnit> get filteredUnits {
switch (scopeFilter.value) {
switch (state.scopeFilter) {
case SystemdScopeFilter.all:
return units.value;
return state.units;
case SystemdScopeFilter.system:
return units.value.where((unit) => unit.scope == SystemdUnitScope.system).toList();
return state.units.where((unit) => unit.scope == SystemdUnitScope.system).toList();
case SystemdScopeFilter.user:
return units.value.where((unit) => unit.scope == SystemdUnitScope.user).toList();
return state.units.where((unit) => unit.scope == SystemdUnitScope.user).toList();
}
}
void setScopeFilter(SystemdScopeFilter filter) {
state = state.copyWith(scopeFilter: filter);
}
Future<void> getUnits() async {
isBusy.value = true;
state = state.copyWith(isBusy: true);
try {
final client = _si.value.client;
final client = _si.client;
final result = await client!.execForOutput(_getUnitsCmd);
final units = result.split('\n');
@@ -57,12 +69,11 @@ final class SystemdProvider {
final parsedUserUnits = await _parseUnitObj(userUnits, SystemdUnitScope.user);
final parsedSystemUnits = await _parseUnitObj(systemUnits, SystemdUnitScope.system);
this.units.value = [...parsedUserUnits, ...parsedSystemUnits];
state = state.copyWith(units: [...parsedUserUnits, ...parsedSystemUnits], isBusy: false);
} catch (e, s) {
dprint('Parse systemd', e, s);
state = state.copyWith(isBusy: false);
}
isBusy.value = false;
}
Future<List<SystemdUnit>> _parseUnitObj(List<String> unitNames, SystemdUnitScope scope) async {
@@ -75,7 +86,7 @@ for unit in ${unitNames_.join(' ')}; do
echo -n "\n${ScriptConstants.separator}\n"
done
''';
final client = _si.value.client!;
final client = _si.client!;
final result = await client.execForOutput(script);
final units = result.split(ScriptConstants.separator);

View File

@@ -0,0 +1,283 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'systemd.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SystemdState {
bool get isBusy; List<SystemdUnit> get units; SystemdScopeFilter get scopeFilter;
/// Create a copy of SystemdState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SystemdStateCopyWith<SystemdState> get copyWith => _$SystemdStateCopyWithImpl<SystemdState>(this as SystemdState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SystemdState&&(identical(other.isBusy, isBusy) || other.isBusy == isBusy)&&const DeepCollectionEquality().equals(other.units, units)&&(identical(other.scopeFilter, scopeFilter) || other.scopeFilter == scopeFilter));
}
@override
int get hashCode => Object.hash(runtimeType,isBusy,const DeepCollectionEquality().hash(units),scopeFilter);
@override
String toString() {
return 'SystemdState(isBusy: $isBusy, units: $units, scopeFilter: $scopeFilter)';
}
}
/// @nodoc
abstract mixin class $SystemdStateCopyWith<$Res> {
factory $SystemdStateCopyWith(SystemdState value, $Res Function(SystemdState) _then) = _$SystemdStateCopyWithImpl;
@useResult
$Res call({
bool isBusy, List<SystemdUnit> units, SystemdScopeFilter scopeFilter
});
}
/// @nodoc
class _$SystemdStateCopyWithImpl<$Res>
implements $SystemdStateCopyWith<$Res> {
_$SystemdStateCopyWithImpl(this._self, this._then);
final SystemdState _self;
final $Res Function(SystemdState) _then;
/// Create a copy of SystemdState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? isBusy = null,Object? units = null,Object? scopeFilter = null,}) {
return _then(_self.copyWith(
isBusy: null == isBusy ? _self.isBusy : isBusy // ignore: cast_nullable_to_non_nullable
as bool,units: null == units ? _self.units : units // ignore: cast_nullable_to_non_nullable
as List<SystemdUnit>,scopeFilter: null == scopeFilter ? _self.scopeFilter : scopeFilter // ignore: cast_nullable_to_non_nullable
as SystemdScopeFilter,
));
}
}
/// Adds pattern-matching-related methods to [SystemdState].
extension SystemdStatePatterns on SystemdState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SystemdState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SystemdState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SystemdState value) $default,){
final _that = this;
switch (_that) {
case _SystemdState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SystemdState value)? $default,){
final _that = this;
switch (_that) {
case _SystemdState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isBusy, List<SystemdUnit> units, SystemdScopeFilter scopeFilter)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SystemdState() when $default != null:
return $default(_that.isBusy,_that.units,_that.scopeFilter);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isBusy, List<SystemdUnit> units, SystemdScopeFilter scopeFilter) $default,) {final _that = this;
switch (_that) {
case _SystemdState():
return $default(_that.isBusy,_that.units,_that.scopeFilter);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isBusy, List<SystemdUnit> units, SystemdScopeFilter scopeFilter)? $default,) {final _that = this;
switch (_that) {
case _SystemdState() when $default != null:
return $default(_that.isBusy,_that.units,_that.scopeFilter);case _:
return null;
}
}
}
/// @nodoc
class _SystemdState implements SystemdState {
const _SystemdState({this.isBusy = false, final List<SystemdUnit> units = const <SystemdUnit>[], this.scopeFilter = SystemdScopeFilter.all}): _units = units;
@override@JsonKey() final bool isBusy;
final List<SystemdUnit> _units;
@override@JsonKey() List<SystemdUnit> get units {
if (_units is EqualUnmodifiableListView) return _units;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_units);
}
@override@JsonKey() final SystemdScopeFilter scopeFilter;
/// Create a copy of SystemdState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SystemdStateCopyWith<_SystemdState> get copyWith => __$SystemdStateCopyWithImpl<_SystemdState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SystemdState&&(identical(other.isBusy, isBusy) || other.isBusy == isBusy)&&const DeepCollectionEquality().equals(other._units, _units)&&(identical(other.scopeFilter, scopeFilter) || other.scopeFilter == scopeFilter));
}
@override
int get hashCode => Object.hash(runtimeType,isBusy,const DeepCollectionEquality().hash(_units),scopeFilter);
@override
String toString() {
return 'SystemdState(isBusy: $isBusy, units: $units, scopeFilter: $scopeFilter)';
}
}
/// @nodoc
abstract mixin class _$SystemdStateCopyWith<$Res> implements $SystemdStateCopyWith<$Res> {
factory _$SystemdStateCopyWith(_SystemdState value, $Res Function(_SystemdState) _then) = __$SystemdStateCopyWithImpl;
@override @useResult
$Res call({
bool isBusy, List<SystemdUnit> units, SystemdScopeFilter scopeFilter
});
}
/// @nodoc
class __$SystemdStateCopyWithImpl<$Res>
implements _$SystemdStateCopyWith<$Res> {
__$SystemdStateCopyWithImpl(this._self, this._then);
final _SystemdState _self;
final $Res Function(_SystemdState) _then;
/// Create a copy of SystemdState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? isBusy = null,Object? units = null,Object? scopeFilter = null,}) {
return _then(_SystemdState(
isBusy: null == isBusy ? _self.isBusy : isBusy // ignore: cast_nullable_to_non_nullable
as bool,units: null == units ? _self._units : units // ignore: cast_nullable_to_non_nullable
as List<SystemdUnit>,scopeFilter: null == scopeFilter ? _self.scopeFilter : scopeFilter // ignore: cast_nullable_to_non_nullable
as SystemdScopeFilter,
));
}
}
// dart format on

View File

@@ -0,0 +1,163 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'systemd.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$systemdNotifierHash() => r'98466bd176518545be49cae52f8dbe12af3a88a6';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
abstract class _$SystemdNotifier
extends BuildlessAutoDisposeNotifier<SystemdState> {
late final Spi spi;
SystemdState build(Spi spi);
}
/// See also [SystemdNotifier].
@ProviderFor(SystemdNotifier)
const systemdNotifierProvider = SystemdNotifierFamily();
/// See also [SystemdNotifier].
class SystemdNotifierFamily extends Family<SystemdState> {
/// See also [SystemdNotifier].
const SystemdNotifierFamily();
/// See also [SystemdNotifier].
SystemdNotifierProvider call(Spi spi) {
return SystemdNotifierProvider(spi);
}
@override
SystemdNotifierProvider getProviderOverride(
covariant SystemdNotifierProvider provider,
) {
return call(provider.spi);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'systemdNotifierProvider';
}
/// See also [SystemdNotifier].
class SystemdNotifierProvider
extends AutoDisposeNotifierProviderImpl<SystemdNotifier, SystemdState> {
/// See also [SystemdNotifier].
SystemdNotifierProvider(Spi spi)
: this._internal(
() => SystemdNotifier()..spi = spi,
from: systemdNotifierProvider,
name: r'systemdNotifierProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$systemdNotifierHash,
dependencies: SystemdNotifierFamily._dependencies,
allTransitiveDependencies:
SystemdNotifierFamily._allTransitiveDependencies,
spi: spi,
);
SystemdNotifierProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.spi,
}) : super.internal();
final Spi spi;
@override
SystemdState runNotifierBuild(covariant SystemdNotifier notifier) {
return notifier.build(spi);
}
@override
Override overrideWith(SystemdNotifier Function() create) {
return ProviderOverride(
origin: this,
override: SystemdNotifierProvider._internal(
() => create()..spi = spi,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
spi: spi,
),
);
}
@override
AutoDisposeNotifierProviderElement<SystemdNotifier, SystemdState>
createElement() {
return _SystemdNotifierProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SystemdNotifierProvider && other.spi == spi;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, spi.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin SystemdNotifierRef on AutoDisposeNotifierProviderRef<SystemdState> {
/// The parameter `spi` of this provider.
Spi get spi;
}
class _SystemdNotifierProviderElement
extends AutoDisposeNotifierProviderElement<SystemdNotifier, SystemdState>
with SystemdNotifierRef {
_SystemdNotifierProviderElement(super.provider);
@override
Spi get spi => (origin as SystemdNotifierProvider).spi;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -1,56 +1,63 @@
import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:server_box/data/res/store.dart';
import 'package:xterm/core.dart';
class VirtKeyProvider extends TerminalInputHandler with ChangeNotifier {
VirtKeyProvider();
part 'virtual_keyboard.g.dart';
part 'virtual_keyboard.freezed.dart';
bool _ctrl = false;
bool get ctrl => _ctrl;
set ctrl(bool value) {
if (value != _ctrl) {
_ctrl = value;
notifyListeners();
@freezed
abstract class VirtKeyState with _$VirtKeyState {
const factory VirtKeyState({
@Default(false) final bool ctrl,
@Default(false) final bool alt,
@Default(false) final bool shift,
}) = _VirtKeyState;
}
@riverpod
class VirtKeyboard extends _$VirtKeyboard implements TerminalInputHandler {
@override
VirtKeyState build() {
return const VirtKeyState();
}
bool get ctrl => state.ctrl;
bool get alt => state.alt;
bool get shift => state.shift;
void setCtrl(bool value) {
if (value != state.ctrl) {
state = state.copyWith(ctrl: value);
}
}
bool _alt = false;
bool get alt => _alt;
set alt(bool value) {
if (value != _alt) {
_alt = value;
notifyListeners();
void setAlt(bool value) {
if (value != state.alt) {
state = state.copyWith(alt: value);
}
}
bool _shift = false;
bool get shift => _shift;
set shift(bool value) {
if (value != _shift) {
_shift = value;
notifyListeners();
void setShift(bool value) {
if (value != state.shift) {
state = state.copyWith(shift: value);
}
}
void reset(TerminalKeyboardEvent e) {
if (e.ctrl) {
ctrl = false;
}
if (e.alt) {
alt = false;
}
if (e.shift) {
shift = false;
}
notifyListeners();
state = state.copyWith(
ctrl: e.ctrl ? false : state.ctrl,
alt: e.alt ? false : state.alt,
shift: e.shift ? false : state.shift,
);
}
@override
String? call(TerminalKeyboardEvent event) {
final e = event.copyWith(
ctrl: event.ctrl || ctrl,
alt: event.alt || alt,
shift: event.shift || shift,
ctrl: event.ctrl || state.ctrl,
alt: event.alt || state.alt,
shift: event.shift || state.shift,
);
if (Stores.setting.sshVirtualKeyAutoOff.fetch()) {
reset(e);

View File

@@ -0,0 +1,277 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'virtual_keyboard.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$VirtKeyState {
bool get ctrl; bool get alt; bool get shift;
/// Create a copy of VirtKeyState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$VirtKeyStateCopyWith<VirtKeyState> get copyWith => _$VirtKeyStateCopyWithImpl<VirtKeyState>(this as VirtKeyState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is VirtKeyState&&(identical(other.ctrl, ctrl) || other.ctrl == ctrl)&&(identical(other.alt, alt) || other.alt == alt)&&(identical(other.shift, shift) || other.shift == shift));
}
@override
int get hashCode => Object.hash(runtimeType,ctrl,alt,shift);
@override
String toString() {
return 'VirtKeyState(ctrl: $ctrl, alt: $alt, shift: $shift)';
}
}
/// @nodoc
abstract mixin class $VirtKeyStateCopyWith<$Res> {
factory $VirtKeyStateCopyWith(VirtKeyState value, $Res Function(VirtKeyState) _then) = _$VirtKeyStateCopyWithImpl;
@useResult
$Res call({
bool ctrl, bool alt, bool shift
});
}
/// @nodoc
class _$VirtKeyStateCopyWithImpl<$Res>
implements $VirtKeyStateCopyWith<$Res> {
_$VirtKeyStateCopyWithImpl(this._self, this._then);
final VirtKeyState _self;
final $Res Function(VirtKeyState) _then;
/// Create a copy of VirtKeyState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? ctrl = null,Object? alt = null,Object? shift = null,}) {
return _then(_self.copyWith(
ctrl: null == ctrl ? _self.ctrl : ctrl // ignore: cast_nullable_to_non_nullable
as bool,alt: null == alt ? _self.alt : alt // ignore: cast_nullable_to_non_nullable
as bool,shift: null == shift ? _self.shift : shift // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// Adds pattern-matching-related methods to [VirtKeyState].
extension VirtKeyStatePatterns on VirtKeyState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _VirtKeyState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _VirtKeyState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _VirtKeyState value) $default,){
final _that = this;
switch (_that) {
case _VirtKeyState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _VirtKeyState value)? $default,){
final _that = this;
switch (_that) {
case _VirtKeyState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool ctrl, bool alt, bool shift)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _VirtKeyState() when $default != null:
return $default(_that.ctrl,_that.alt,_that.shift);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool ctrl, bool alt, bool shift) $default,) {final _that = this;
switch (_that) {
case _VirtKeyState():
return $default(_that.ctrl,_that.alt,_that.shift);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool ctrl, bool alt, bool shift)? $default,) {final _that = this;
switch (_that) {
case _VirtKeyState() when $default != null:
return $default(_that.ctrl,_that.alt,_that.shift);case _:
return null;
}
}
}
/// @nodoc
class _VirtKeyState implements VirtKeyState {
const _VirtKeyState({this.ctrl = false, this.alt = false, this.shift = false});
@override@JsonKey() final bool ctrl;
@override@JsonKey() final bool alt;
@override@JsonKey() final bool shift;
/// Create a copy of VirtKeyState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$VirtKeyStateCopyWith<_VirtKeyState> get copyWith => __$VirtKeyStateCopyWithImpl<_VirtKeyState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _VirtKeyState&&(identical(other.ctrl, ctrl) || other.ctrl == ctrl)&&(identical(other.alt, alt) || other.alt == alt)&&(identical(other.shift, shift) || other.shift == shift));
}
@override
int get hashCode => Object.hash(runtimeType,ctrl,alt,shift);
@override
String toString() {
return 'VirtKeyState(ctrl: $ctrl, alt: $alt, shift: $shift)';
}
}
/// @nodoc
abstract mixin class _$VirtKeyStateCopyWith<$Res> implements $VirtKeyStateCopyWith<$Res> {
factory _$VirtKeyStateCopyWith(_VirtKeyState value, $Res Function(_VirtKeyState) _then) = __$VirtKeyStateCopyWithImpl;
@override @useResult
$Res call({
bool ctrl, bool alt, bool shift
});
}
/// @nodoc
class __$VirtKeyStateCopyWithImpl<$Res>
implements _$VirtKeyStateCopyWith<$Res> {
__$VirtKeyStateCopyWithImpl(this._self, this._then);
final _VirtKeyState _self;
final $Res Function(_VirtKeyState) _then;
/// Create a copy of VirtKeyState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? ctrl = null,Object? alt = null,Object? shift = null,}) {
return _then(_VirtKeyState(
ctrl: null == ctrl ? _self.ctrl : ctrl // ignore: cast_nullable_to_non_nullable
as bool,alt: null == alt ? _self.alt : alt // ignore: cast_nullable_to_non_nullable
as bool,shift: null == shift ? _self.shift : shift // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
// dart format on

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'virtual_keyboard.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$virtKeyboardHash() => r'1327d412bfb0dd261f3b555f353a8852b4f753e5';
/// See also [VirtKeyboard].
@ProviderFor(VirtKeyboard)
final virtKeyboardProvider =
AutoDisposeNotifierProvider<VirtKeyboard, VirtKeyState>.internal(
VirtKeyboard.new,
name: r'virtKeyboardProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$virtKeyboardHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$VirtKeyboard = AutoDisposeNotifier<VirtKeyState>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -3,6 +3,6 @@
abstract class BuildData {
static const String name = "ServerBox";
static const int build = 1220;
static const int build = 1231;
static const int script = 68;
}

View File

@@ -124,6 +124,8 @@ abstract final class GithubIds {
'CreeperKong',
'zxf945',
'cnen2018',
'xiaomeng9597',
'mingzhao2019',
};
}

View File

@@ -277,4 +277,7 @@ class SettingStore extends HiveStore {
/// The backup password
late final backupasswd = SecureProp('bakPasswd');
/// Whether to read SSH config from ~/.ssh/config on first time
late final firstTimeReadSSHCfg = propertyDefault('firstTimeReadSSHCfg', true);
}

View File

@@ -185,17 +185,17 @@ abstract class AppLocalizations {
/// **'Automatic home widget update'**
String get autoUpdateHomeWidget;
/// No description provided for @backupTip.
/// No description provided for @backupEncrypted.
///
/// In en, this message translates to:
/// **'The exported data can be encrypted with password. \nPlease keep it safe.'**
String get backupTip;
/// **'Backup is encrypted'**
String get backupEncrypted;
/// No description provided for @backupVersionNotMatch.
/// No description provided for @backupNotEncrypted.
///
/// In en, this message translates to:
/// **'Backup version is not match.'**
String get backupVersionNotMatch;
/// **'Backup is not encrypted'**
String get backupNotEncrypted;
/// No description provided for @backupPassword.
///
@@ -203,6 +203,18 @@ abstract class AppLocalizations {
/// **'Backup password'**
String get backupPassword;
/// No description provided for @backupPasswordRemoved.
///
/// In en, this message translates to:
/// **'Backup password removed'**
String get backupPasswordRemoved;
/// No description provided for @backupPasswordSet.
///
/// In en, this message translates to:
/// **'Backup password set'**
String get backupPasswordSet;
/// No description provided for @backupPasswordTip.
///
/// In en, this message translates to:
@@ -215,29 +227,17 @@ abstract class AppLocalizations {
/// **'Incorrect backup password'**
String get backupPasswordWrong;
/// No description provided for @backupEncrypted.
/// No description provided for @backupTip.
///
/// In en, this message translates to:
/// **'Backup is encrypted'**
String get backupEncrypted;
/// **'The exported data can be encrypted with password. \nPlease keep it safe.'**
String get backupTip;
/// No description provided for @backupNotEncrypted.
/// No description provided for @backupVersionNotMatch.
///
/// In en, this message translates to:
/// **'Backup is not encrypted'**
String get backupNotEncrypted;
/// No description provided for @backupPasswordSet.
///
/// In en, this message translates to:
/// **'Backup password set'**
String get backupPasswordSet;
/// No description provided for @backupPasswordRemoved.
///
/// In en, this message translates to:
/// **'Backup password removed'**
String get backupPasswordRemoved;
/// **'Backup version is not match.'**
String get backupVersionNotMatch;
/// No description provided for @battery.
///
@@ -1202,6 +1202,84 @@ abstract class AppLocalizations {
/// **'Spent time: {time}'**
String spentTime(Object time);
/// No description provided for @sshConfigAllExist.
///
/// In en, this message translates to:
/// **'All servers already exist ({duplicateCount} duplicates found)'**
String sshConfigAllExist(Object duplicateCount);
/// No description provided for @sshConfigDuplicatesSkipped.
///
/// In en, this message translates to:
/// **'{duplicateCount} duplicates will be skipped'**
String sshConfigDuplicatesSkipped(Object duplicateCount);
/// No description provided for @sshConfigFound.
///
/// In en, this message translates to:
/// **'We found SSH configuration on your system.'**
String get sshConfigFound;
/// No description provided for @sshConfigFoundServers.
///
/// In en, this message translates to:
/// **'Found {totalCount} servers'**
String sshConfigFoundServers(Object totalCount);
/// No description provided for @sshConfigImport.
///
/// In en, this message translates to:
/// **'SSH Config Import'**
String get sshConfigImport;
/// No description provided for @sshConfigImportHelp.
///
/// In en, this message translates to:
/// **'Only basic information can be imported, for example: IP/Port.'**
String get sshConfigImportHelp;
/// No description provided for @sshConfigImportPermission.
///
/// In en, this message translates to:
/// **'Would you like to give permission to read ~/.ssh/config and automatically import server settings?'**
String get sshConfigImportPermission;
/// No description provided for @sshConfigImportTip.
///
/// In en, this message translates to:
/// **'Prompt to read ~/.ssh/config on first server creation'**
String get sshConfigImportTip;
/// No description provided for @sshConfigImported.
///
/// In en, this message translates to:
/// **'Imported {count} servers from SSH config'**
String sshConfigImported(Object count);
/// No description provided for @sshConfigManualSelect.
///
/// In en, this message translates to:
/// **'Would you like to select the SSH config file manually?'**
String get sshConfigManualSelect;
/// No description provided for @sshConfigNoServers.
///
/// In en, this message translates to:
/// **'No servers found in SSH config'**
String get sshConfigNoServers;
/// No description provided for @sshConfigPermissionDenied.
///
/// In en, this message translates to:
/// **'Cannot access SSH config file due to macOS permissions.'**
String get sshConfigPermissionDenied;
/// No description provided for @sshConfigServersToImport.
///
/// In en, this message translates to:
/// **'{importCount} servers will be imported'**
String sshConfigServersToImport(Object importCount);
/// No description provided for @sshTermHelp.
///
/// In en, this message translates to:

View File

@@ -46,16 +46,20 @@ class AppLocalizationsDe extends AppLocalizations {
String get autoUpdateHomeWidget => 'Home-Widget automatisch aktualisieren';
@override
String get backupTip =>
'Die exportierten Daten können mit einem Passwort verschlüsselt werden. \nBitte sicher aufbewahren.';
String get backupEncrypted => 'Backup ist verschlüsselt';
@override
String get backupVersionNotMatch =>
'Die Backup-Version stimmt nicht überein.';
String get backupNotEncrypted => 'Backup ist nicht verschlüsselt';
@override
String get backupPassword => 'Backup-Passwort';
@override
String get backupPasswordRemoved => 'Backup-Passwort entfernt';
@override
String get backupPasswordSet => 'Backup-Passwort gesetzt';
@override
String get backupPasswordTip =>
'Setzen Sie ein Passwort, um Backup-Dateien zu verschlüsseln. Leer lassen, um Verschlüsselung zu deaktivieren.';
@@ -64,16 +68,12 @@ class AppLocalizationsDe extends AppLocalizations {
String get backupPasswordWrong => 'Falsches Backup-Passwort';
@override
String get backupEncrypted => 'Backup ist verschlüsselt';
String get backupTip =>
'Die exportierten Daten können mit einem Passwort verschlüsselt werden. \nBitte sicher aufbewahren.';
@override
String get backupNotEncrypted => 'Backup ist nicht verschlüsselt';
@override
String get backupPasswordSet => 'Backup-Passwort gesetzt';
@override
String get backupPasswordRemoved => 'Backup-Passwort entfernt';
String get backupVersionNotMatch =>
'Die Backup-Version stimmt nicht überein.';
@override
String get battery => 'Batterie';
@@ -606,6 +606,62 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Benötigte Zeit: $time';
}
@override
String sshConfigAllExist(Object duplicateCount) {
return 'Alle Server existieren bereits ($duplicateCount Duplikate gefunden)';
}
@override
String sshConfigDuplicatesSkipped(Object duplicateCount) {
return '$duplicateCount Duplikate werden übersprungen';
}
@override
String get sshConfigFound =>
'Wir haben SSH-Konfiguration auf Ihrem System gefunden.';
@override
String sshConfigFoundServers(Object totalCount) {
return '$totalCount Server gefunden';
}
@override
String get sshConfigImport => 'SSH-Konfiguration importieren';
@override
String get sshConfigImportHelp =>
'Es können nur Basisinformationen importiert werden, zum Beispiel: IP/Port.';
@override
String get sshConfigImportPermission =>
'Möchten Sie die Berechtigung erteilen, ~/.ssh/config zu lesen und Server-Einstellungen automatisch zu importieren?';
@override
String get sshConfigImportTip =>
'Bei der ersten Server-Erstellung zum Lesen von ~/.ssh/config auffordern';
@override
String sshConfigImported(Object count) {
return '$count Server aus SSH-Konfiguration importiert';
}
@override
String get sshConfigManualSelect =>
'Möchten Sie die SSH-Konfigurationsdatei manuell auswählen?';
@override
String get sshConfigNoServers =>
'Keine Server in der SSH-Konfiguration gefunden';
@override
String get sshConfigPermissionDenied =>
'Aufgrund der macOS-Berechtigungen kann nicht auf die SSH-Konfigurationsdatei zugegriffen werden.';
@override
String sshConfigServersToImport(Object importCount) {
return '$importCount Server werden importiert';
}
@override
String get sshTermHelp =>
'Wenn das Terminal scrollbar ist, kann durch horizontales Ziehen Text ausgewählt werden. Durch Klicken auf die Tastentaste wird die Tastatur ein- oder ausgeschaltet. Das Dateisymbol öffnet den aktuellen Pfad SFTP. Die Zwischenablage-Schaltfläche kopiert den Inhalt, wenn Text ausgewählt ist, und fügt Inhalte aus der Zwischenablage in das Terminal ein, wenn kein Text ausgewählt ist und Inhalte in der Zwischenablage vorhanden sind. Das Codesymbol fügt Code-Schnipsel ins Terminal ein und führt sie aus.';

View File

@@ -46,15 +46,20 @@ class AppLocalizationsEn extends AppLocalizations {
String get autoUpdateHomeWidget => 'Automatic home widget update';
@override
String get backupTip =>
'The exported data can be encrypted with password. \nPlease keep it safe.';
String get backupEncrypted => 'Backup is encrypted';
@override
String get backupVersionNotMatch => 'Backup version is not match.';
String get backupNotEncrypted => 'Backup is not encrypted';
@override
String get backupPassword => 'Backup password';
@override
String get backupPasswordRemoved => 'Backup password removed';
@override
String get backupPasswordSet => 'Backup password set';
@override
String get backupPasswordTip =>
'Set a password to encrypt backup files. Leave empty to disable encryption.';
@@ -63,16 +68,11 @@ class AppLocalizationsEn extends AppLocalizations {
String get backupPasswordWrong => 'Incorrect backup password';
@override
String get backupEncrypted => 'Backup is encrypted';
String get backupTip =>
'The exported data can be encrypted with password. \nPlease keep it safe.';
@override
String get backupNotEncrypted => 'Backup is not encrypted';
@override
String get backupPasswordSet => 'Backup password set';
@override
String get backupPasswordRemoved => 'Backup password removed';
String get backupVersionNotMatch => 'Backup version is not match.';
@override
String get battery => 'Battery';
@@ -602,6 +602,60 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Spent time: $time';
}
@override
String sshConfigAllExist(Object duplicateCount) {
return 'All servers already exist ($duplicateCount duplicates found)';
}
@override
String sshConfigDuplicatesSkipped(Object duplicateCount) {
return '$duplicateCount duplicates will be skipped';
}
@override
String get sshConfigFound => 'We found SSH configuration on your system.';
@override
String sshConfigFoundServers(Object totalCount) {
return 'Found $totalCount servers';
}
@override
String get sshConfigImport => 'SSH Config Import';
@override
String get sshConfigImportHelp =>
'Only basic information can be imported, for example: IP/Port.';
@override
String get sshConfigImportPermission =>
'Would you like to give permission to read ~/.ssh/config and automatically import server settings?';
@override
String get sshConfigImportTip =>
'Prompt to read ~/.ssh/config on first server creation';
@override
String sshConfigImported(Object count) {
return 'Imported $count servers from SSH config';
}
@override
String get sshConfigManualSelect =>
'Would you like to select the SSH config file manually?';
@override
String get sshConfigNoServers => 'No servers found in SSH config';
@override
String get sshConfigPermissionDenied =>
'Cannot access SSH config file due to macOS permissions.';
@override
String sshConfigServersToImport(Object importCount) {
return '$importCount servers will be imported';
}
@override
String get sshTermHelp =>
'When the terminal is scrollable, dragging horizontally can select text. Clicking the keyboard button turns the keyboard on/off. The file icon opens the current path SFTP. The clipboard button copies the content when text is selected, and pastes content from the clipboard into the terminal when no text is selected and there is content on the clipboard. The code icon pastes code snippets into the terminal and executes them.';

View File

@@ -46,16 +46,20 @@ class AppLocalizationsEs extends AppLocalizations {
'Actualizar automáticamente el widget del escritorio';
@override
String get backupTip =>
'Los datos exportados pueden ser encriptados con contraseña. \nPor favor guárdalos en un lugar seguro.';
String get backupEncrypted => 'El respaldo está encriptado';
@override
String get backupVersionNotMatch =>
'La versión de la copia de seguridad no coincide, no se puede restaurar';
String get backupNotEncrypted => 'El respaldo no está encriptado';
@override
String get backupPassword => 'Contraseña de respaldo';
@override
String get backupPasswordRemoved => 'Contraseña de respaldo eliminada';
@override
String get backupPasswordSet => 'Contraseña de respaldo establecida';
@override
String get backupPasswordTip =>
'Establece una contraseña para encriptar archivos de respaldo. Déjalo vacío para desactivar la encriptación.';
@@ -64,16 +68,12 @@ class AppLocalizationsEs extends AppLocalizations {
String get backupPasswordWrong => 'Contraseña de respaldo incorrecta';
@override
String get backupEncrypted => 'El respaldo está encriptado';
String get backupTip =>
'Los datos exportados pueden ser encriptados con contraseña. \nPor favor guárdalos en un lugar seguro.';
@override
String get backupNotEncrypted => 'El respaldo no está encriptado';
@override
String get backupPasswordSet => 'Contraseña de respaldo establecida';
@override
String get backupPasswordRemoved => 'Contraseña de respaldo eliminada';
String get backupVersionNotMatch =>
'La versión de la copia de seguridad no coincide, no se puede restaurar';
@override
String get battery => 'Batería';
@@ -609,6 +609,61 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Tiempo gastado: $time';
}
@override
String sshConfigAllExist(Object duplicateCount) {
return 'Todos los servidores ya existen (se encontraron $duplicateCount duplicados)';
}
@override
String sshConfigDuplicatesSkipped(Object duplicateCount) {
return 'Se omitirán $duplicateCount duplicados';
}
@override
String get sshConfigFound => 'Encontramos configuración SSH en tu sistema';
@override
String sshConfigFoundServers(Object totalCount) {
return 'Se encontraron $totalCount servidores';
}
@override
String get sshConfigImport => 'Importar Configuración SSH';
@override
String get sshConfigImportHelp =>
'Solo se pueden importar datos básicos, por ejemplo: IP/Puerto.';
@override
String get sshConfigImportPermission =>
'¿Te gustaría dar permiso para leer ~/.ssh/config e importar automáticamente la configuración de servidores?';
@override
String get sshConfigImportTip =>
'Sugerencia para leer ~/.ssh/config al crear el primer servidor';
@override
String sshConfigImported(Object count) {
return 'Se importaron $count servidores desde la configuración SSH';
}
@override
String get sshConfigManualSelect =>
'¿Te gustaría seleccionar manualmente el archivo de configuración SSH?';
@override
String get sshConfigNoServers =>
'No se encontraron servidores en la configuración SSH';
@override
String get sshConfigPermissionDenied =>
'No se puede acceder al archivo de configuración SSH debido a los permisos de macOS.';
@override
String sshConfigServersToImport(Object importCount) {
return 'Se importarán $importCount servidores';
}
@override
String get sshTermHelp =>
'Cuando el terminal es desplazable, arrastrar horizontalmente puede seleccionar texto. Hacer clic en el botón del teclado enciende/apaga el teclado. El icono de archivo abre el SFTP de la ruta actual. El botón del portapapeles copia el contenido cuando se selecciona texto y pega el contenido del portapapeles en el terminal cuando no se selecciona texto y hay contenido en el portapapeles. El icono de código pega fragmentos de código en el terminal y los ejecuta.';

View File

@@ -46,16 +46,20 @@ class AppLocalizationsFr extends AppLocalizations {
'Mise à jour automatique du widget d\'accueil';
@override
String get backupTip =>
'Les données exportées peuvent être chiffrées avec un mot de passe. \nVeuillez les garder en sécurité.';
String get backupEncrypted => 'La sauvegarde est chiffrée';
@override
String get backupVersionNotMatch =>
'La version de sauvegarde ne correspond pas.';
String get backupNotEncrypted => 'La sauvegarde n\'est pas chiffrée';
@override
String get backupPassword => 'Mot de passe de sauvegarde';
@override
String get backupPasswordRemoved => 'Mot de passe de sauvegarde supprimé';
@override
String get backupPasswordSet => 'Mot de passe de sauvegarde défini';
@override
String get backupPasswordTip =>
'Définissez un mot de passe pour chiffrer les fichiers de sauvegarde. Laissez vide pour désactiver le chiffrement.';
@@ -64,16 +68,12 @@ class AppLocalizationsFr extends AppLocalizations {
String get backupPasswordWrong => 'Mot de passe de sauvegarde incorrect';
@override
String get backupEncrypted => 'La sauvegarde est chiffrée';
String get backupTip =>
'Les données exportées peuvent être chiffrées avec un mot de passe. \nVeuillez les garder en sécurité.';
@override
String get backupNotEncrypted => 'La sauvegarde n\'est pas chiffrée';
@override
String get backupPasswordSet => 'Mot de passe de sauvegarde défini';
@override
String get backupPasswordRemoved => 'Mot de passe de sauvegarde supprimé';
String get backupVersionNotMatch =>
'La version de sauvegarde ne correspond pas.';
@override
String get battery => 'Batterie';
@@ -610,6 +610,62 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Temps écoulé : $time';
}
@override
String sshConfigAllExist(Object duplicateCount) {
return 'Tous les serveurs existent déjà ($duplicateCount doublons trouvés)';
}
@override
String sshConfigDuplicatesSkipped(Object duplicateCount) {
return '$duplicateCount doublons seront ignorés';
}
@override
String get sshConfigFound =>
'Nous avons trouvé une configuration SSH sur votre système.';
@override
String sshConfigFoundServers(Object totalCount) {
return '$totalCount serveurs trouvés';
}
@override
String get sshConfigImport => 'Importation de configuration SSH';
@override
String get sshConfigImportHelp =>
'Seules les informations de base peuvent être importées, par exemple : IP/Port.';
@override
String get sshConfigImportPermission =>
'Souhaitez-vous donner la permission de lire ~/.ssh/config et d\'importer automatiquement les paramètres du serveur ?';
@override
String get sshConfigImportTip =>
'Proposer de lire ~/.ssh/config lors de la première création de serveur';
@override
String sshConfigImported(Object count) {
return '$count serveurs importés depuis la configuration SSH';
}
@override
String get sshConfigManualSelect =>
'Souhaitez-vous sélectionner manuellement le fichier de configuration SSH ?';
@override
String get sshConfigNoServers =>
'Aucun serveur trouvé dans la configuration SSH';
@override
String get sshConfigPermissionDenied =>
'Impossible d\'accéder au fichier de configuration SSH en raison des permissions macOS.';
@override
String sshConfigServersToImport(Object importCount) {
return '$importCount serveurs seront importés';
}
@override
String get sshTermHelp =>
'Lorsque le terminal est défilable, faire glisser horizontalement permet de sélectionner du texte. En cliquant sur le bouton du clavier, vous activez/désactivez le clavier. L\'icône de fichier ouvre le chemin actuel SFTP. Le bouton du presse-papiers copie le contenu lorsque du texte est sélectionné, et colle le contenu du presse-papiers dans le terminal lorsqu\'aucun texte n\'est sélectionné et qu\'il y a du contenu dans le presse-papiers. L\'icône de code colle des extraits de code dans le terminal et les exécute.';
@@ -798,5 +854,5 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get writeScriptTip =>
'Après la connexion au serveur, un script sera écrit dans `~/.config/server_box` \n | `/tmp/server_box` pour surveiller létat du système. Vous pouvez examiner le contenu du script.';
'Après la connexion au serveur, un script sera écrit dans `~/.config/server_box` \n | `/tmp/server_box` pour surveiller l\'état du système. Vous pouvez examiner le contenu du script.';
}

View File

@@ -46,15 +46,20 @@ class AppLocalizationsId extends AppLocalizations {
String get autoUpdateHomeWidget => 'Widget Rumah Pembaruan Otomatis';
@override
String get backupTip =>
'Data yang diekspor dapat dienkripsi dengan kata sandi. \nHarap jaga keamanannya.';
String get backupEncrypted => 'Cadangan telah dienkripsi';
@override
String get backupVersionNotMatch => 'Versi cadangan tidak cocok.';
String get backupNotEncrypted => 'Cadangan tidak dienkripsi';
@override
String get backupPassword => 'Kata sandi cadangan';
@override
String get backupPasswordRemoved => 'Kata sandi cadangan dihapus';
@override
String get backupPasswordSet => 'Kata sandi cadangan ditetapkan';
@override
String get backupPasswordTip =>
'Setel kata sandi untuk mengenkripsi file cadangan. Biarkan kosong untuk menonaktifkan enkripsi.';
@@ -63,16 +68,11 @@ class AppLocalizationsId extends AppLocalizations {
String get backupPasswordWrong => 'Kata sandi cadangan salah';
@override
String get backupEncrypted => 'Cadangan telah dienkripsi';
String get backupTip =>
'Data yang diekspor dapat dienkripsi dengan kata sandi. \nHarap jaga keamanannya.';
@override
String get backupNotEncrypted => 'Cadangan tidak dienkripsi';
@override
String get backupPasswordSet => 'Kata sandi cadangan ditetapkan';
@override
String get backupPasswordRemoved => 'Kata sandi cadangan dihapus';
String get backupVersionNotMatch => 'Versi cadangan tidak cocok.';
@override
String get battery => 'Baterai';
@@ -603,6 +603,61 @@ class AppLocalizationsId extends AppLocalizations {
return 'Menghabiskan waktu: $time';
}
@override
String sshConfigAllExist(Object duplicateCount) {
return 'Semua server sudah ada (ditemukan $duplicateCount duplikat)';
}
@override
String sshConfigDuplicatesSkipped(Object duplicateCount) {
return '$duplicateCount duplikat akan dilewati';
}
@override
String get sshConfigFound => 'Kami menemukan konfigurasi SSH di sistem Anda';
@override
String sshConfigFoundServers(Object totalCount) {
return 'Ditemukan $totalCount server';
}
@override
String get sshConfigImport => 'Impor Konfigurasi SSH';
@override
String get sshConfigImportHelp =>
'Hanya informasi dasar yang dapat diimpor, misalnya: IP/Port.';
@override
String get sshConfigImportPermission =>
'Apakah Anda ingin memberikan izin untuk membaca ~/.ssh/config dan secara otomatis mengimpor pengaturan server?';
@override
String get sshConfigImportTip =>
'Prompt untuk membaca ~/.ssh/config saat pembuatan server pertama';
@override
String sshConfigImported(Object count) {
return 'Berhasil mengimpor $count server dari konfigurasi SSH';
}
@override
String get sshConfigManualSelect =>
'Apakah Anda ingin memilih file konfigurasi SSH secara manual?';
@override
String get sshConfigNoServers =>
'Tidak ada server yang ditemukan dalam konfigurasi SSH';
@override
String get sshConfigPermissionDenied =>
'Tidak dapat mengakses file konfigurasi SSH karena izin macOS.';
@override
String sshConfigServersToImport(Object importCount) {
return '$importCount server akan diimpor';
}
@override
String get sshTermHelp =>
'Ketika terminal dapat digulirkan, menggeser secara horizontal dapat memilih teks. Mengklik tombol keyboard mengaktifkan/menonaktifkan keyboard. Ikon file membuka SFTP jalur saat ini. Tombol papan klip menyalin konten saat teks dipilih, dan menempelkan konten dari papan klip ke terminal saat tidak ada teks yang dipilih dan ada konten di papan klip. Ikon kode menempelkan potongan kode ke terminal dan mengeksekusinya.';

View File

@@ -43,14 +43,20 @@ class AppLocalizationsJa extends AppLocalizations {
String get autoUpdateHomeWidget => 'ホームウィジェットを自動更新';
@override
String get backupTip => 'エクスポートされたデータはパスワードで暗号化できます。 \n適切に保管してください。';
String get backupEncrypted => 'バックアップは暗号化されています';
@override
String get backupVersionNotMatch => 'バックアップバージョンが一致しないため、復元できません';
String get backupNotEncrypted => 'バックアップは暗号化されていません';
@override
String get backupPassword => 'バックアップパスワード';
@override
String get backupPasswordRemoved => 'バックアップパスワードが削除されました';
@override
String get backupPasswordSet => 'バックアップパスワードが設定されました';
@override
String get backupPasswordTip =>
'バックアップファイルを暗号化するためのパスワードを設定してください。暗号化を無効にするには空白のままにしてください。';
@@ -59,16 +65,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get backupPasswordWrong => 'バックアップパスワードが間違っています';
@override
String get backupEncrypted => 'バックアップは暗号化されています';
String get backupTip => 'エクスポートされたデータはパスワードで暗号化できます。 \n適切に保管してください。';
@override
String get backupNotEncrypted => 'バックアップは暗号化されていません';
@override
String get backupPasswordSet => 'バックアップパスワードが設定されました';
@override
String get backupPasswordRemoved => 'バックアップパスワードが削除されました';
String get backupVersionNotMatch => 'バックアップバージョンが一致しないため、復元できません';
@override
String get battery => 'バッテリー';
@@ -587,6 +587,56 @@ class AppLocalizationsJa extends AppLocalizations {
return '費した時間: $time';
}
@override
String sshConfigAllExist(Object duplicateCount) {
return 'すべてのサーバーがすでに存在します($duplicateCount個の重複が見つかりました';
}
@override
String sshConfigDuplicatesSkipped(Object duplicateCount) {
return '$duplicateCount個の重複がスキップされます';
}
@override
String get sshConfigFound => 'システムにSSH設定が見つかりました。';
@override
String sshConfigFoundServers(Object totalCount) {
return '$totalCount個のサーバーが見つかりました';
}
@override
String get sshConfigImport => 'SSH設定のインポート';
@override
String get sshConfigImportHelp => 'インポートできるのは基本情報のみです。例IP/ポート。';
@override
String get sshConfigImportPermission =>
'~/.ssh/configを読み取ってサーバー設定を自動的にインポートする権限を与えますか';
@override
String get sshConfigImportTip => '初回サーバー作成時に~/.ssh/configの読み取りを促す';
@override
String sshConfigImported(Object count) {
return 'SSH設定から$count個のサーバーをインポートしました';
}
@override
String get sshConfigManualSelect => 'SSH設定ファイルを手動で選択しますか';
@override
String get sshConfigNoServers => 'SSH設定でサーバーが見つかりませんでした';
@override
String get sshConfigPermissionDenied => 'macOSの権限により、SSH設定ファイルにアクセスできません。';
@override
String sshConfigServersToImport(Object importCount) {
return '$importCount個のサーバーがインポートされます';
}
@override
String get sshTermHelp =>
'ターミナルがスクロール可能な場合、横にドラッグするとテキストを選択できます。キーボードボタンをクリックするとキーボードのオン/オフが切り替わります。ファイルアイコンは現在のパスSFTPを開きます。クリップボードボタンは、テキストが選択されているときに内容をコピーし、テキストが選択されておらずクリップボードに内容がある場合には、その内容をターミナルに貼り付けます。コードアイコンは、コードスニペットをターミナルに貼り付けて実行します。';

View File

@@ -46,15 +46,20 @@ class AppLocalizationsNl extends AppLocalizations {
String get autoUpdateHomeWidget => 'Automatische update van home-widget';
@override
String get backupTip =>
'De geëxporteerde gegevens kunnen worden versleuteld met een wachtwoord. \nBewaar deze aub veilig.';
String get backupEncrypted => 'Back-up is versleuteld';
@override
String get backupVersionNotMatch => 'Back-upversie komt niet overeen.';
String get backupNotEncrypted => 'Back-up is niet versleuteld';
@override
String get backupPassword => 'Back-up wachtwoord';
@override
String get backupPasswordRemoved => 'Back-up wachtwoord verwijderd';
@override
String get backupPasswordSet => 'Back-up wachtwoord ingesteld';
@override
String get backupPasswordTip =>
'Stel een wachtwoord in om back-upbestanden te versleutelen. Laat leeg om versleuteling uit te schakelen.';
@@ -63,16 +68,11 @@ class AppLocalizationsNl extends AppLocalizations {
String get backupPasswordWrong => 'Onjuist back-up wachtwoord';
@override
String get backupEncrypted => 'Back-up is versleuteld';
String get backupTip =>
'De geëxporteerde gegevens kunnen worden versleuteld met een wachtwoord. \nBewaar deze aub veilig.';
@override
String get backupNotEncrypted => 'Back-up is niet versleuteld';
@override
String get backupPasswordSet => 'Back-up wachtwoord ingesteld';
@override
String get backupPasswordRemoved => 'Back-up wachtwoord verwijderd';
String get backupVersionNotMatch => 'Back-upversie komt niet overeen.';
@override
String get battery => 'Batterij';
@@ -605,6 +605,61 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Gebruikte tijd: $time';
}
@override
String sshConfigAllExist(Object duplicateCount) {
return 'Alle servers bestaan al ($duplicateCount duplicaten gevonden)';
}
@override
String sshConfigDuplicatesSkipped(Object duplicateCount) {
return '$duplicateCount duplicaten worden overgeslagen';
}
@override
String get sshConfigFound =>
'We hebben SSH-configuratie op uw systeem gevonden';
@override
String sshConfigFoundServers(Object totalCount) {
return '$totalCount servers gevonden';
}
@override
String get sshConfigImport => 'SSH Configuratie Importeren';
@override
String get sshConfigImportHelp =>
'Alleen basisinformatie kan worden geïmporteerd, bijvoorbeeld: IP/Poort.';
@override
String get sshConfigImportPermission =>
'Wilt u toestemming geven om ~/.ssh/config te lezen en automatisch serverinstellingen te importeren?';
@override
String get sshConfigImportTip =>
'Prompt om ~/.ssh/config te lezen bij het aanmaken van de eerste server';
@override
String sshConfigImported(Object count) {
return '$count servers geïmporteerd uit SSH-configuratie';
}
@override
String get sshConfigManualSelect =>
'Wilt u het SSH-configuratiebestand handmatig selecteren?';
@override
String get sshConfigNoServers => 'Geen servers gevonden in SSH-configuratie';
@override
String get sshConfigPermissionDenied =>
'Kan geen toegang krijgen tot SSH-configuratiebestand vanwege macOS-rechten.';
@override
String sshConfigServersToImport(Object importCount) {
return '$importCount servers worden geïmporteerd';
}
@override
String get sshTermHelp =>
'Wanneer het terminal scrollbaar is, kan horizontaal slepen tekst selecteren. Klikken op de toetsenbordknop schakelt het toetsenbord aan/uit. Het bestandsicoon opent de huidige pad SFTP. De klembordknop kopieert de inhoud wanneer tekst is geselecteerd en plakt inhoud van het klembord in de terminal wanneer geen tekst is geselecteerd en er inhoud op het klembord staat. Het code-icoon plakt codefragmenten in de terminal en voert ze uit.';

View File

@@ -46,16 +46,20 @@ class AppLocalizationsPt extends AppLocalizations {
'Atualização automática do widget da tela inicial';
@override
String get backupTip =>
'Os dados exportados podem ser criptografados com senha. \nPor favor, guarde-os com segurança.';
String get backupEncrypted => 'Backup está criptografado';
@override
String get backupVersionNotMatch =>
'Versão de backup não compatível, não é possível restaurar';
String get backupNotEncrypted => 'Backup não está criptografado';
@override
String get backupPassword => 'Senha de backup';
@override
String get backupPasswordRemoved => 'Senha de backup removida';
@override
String get backupPasswordSet => 'Senha de backup definida';
@override
String get backupPasswordTip =>
'Defina uma senha para criptografar arquivos de backup. Deixe vazio para desabilitar a criptografia.';
@@ -64,16 +68,12 @@ class AppLocalizationsPt extends AppLocalizations {
String get backupPasswordWrong => 'Senha de backup incorreta';
@override
String get backupEncrypted => 'Backup está criptografado';
String get backupTip =>
'Os dados exportados podem ser criptografados com senha. \nPor favor, guarde-os com segurança.';
@override
String get backupNotEncrypted => 'Backup não está criptografado';
@override
String get backupPasswordSet => 'Senha de backup definida';
@override
String get backupPasswordRemoved => 'Senha de backup removida';
String get backupVersionNotMatch =>
'Versão de backup não compatível, não é possível restaurar';
@override
String get battery => 'Bateria';
@@ -604,6 +604,61 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Tempo gasto: $time';
}
@override
String sshConfigAllExist(Object duplicateCount) {
return 'Todos os servidores já existem (encontradas $duplicateCount duplicatas)';
}
@override
String sshConfigDuplicatesSkipped(Object duplicateCount) {
return '$duplicateCount duplicatas serão ignoradas';
}
@override
String get sshConfigFound => 'Encontramos configuração SSH no seu sistema';
@override
String sshConfigFoundServers(Object totalCount) {
return 'Encontrados $totalCount servidores';
}
@override
String get sshConfigImport => 'Importar Configuração SSH';
@override
String get sshConfigImportHelp =>
'Só é possível importar informações básicas, por exemplo: IP/Porta.';
@override
String get sshConfigImportPermission =>
'Gostaria de dar permissão para ler ~/.ssh/config e importar automaticamente as configurações do servidor?';
@override
String get sshConfigImportTip =>
'Sugestão para ler ~/.ssh/config na criação do primeiro servidor';
@override
String sshConfigImported(Object count) {
return 'Importados $count servidores da configuração SSH';
}
@override
String get sshConfigManualSelect =>
'Gostaria de selecionar manualmente o arquivo de configuração SSH?';
@override
String get sshConfigNoServers =>
'Nenhum servidor encontrado na configuração SSH';
@override
String get sshConfigPermissionDenied =>
'Não é possível acessar o arquivo de configuração SSH devido às permissões do macOS.';
@override
String sshConfigServersToImport(Object importCount) {
return '$importCount servidores serão importados';
}
@override
String get sshTermHelp =>
'Quando o terminal é rolável, arrastar horizontalmente pode selecionar texto. Clicar no botão do teclado ativa/desativa o teclado. O ícone de arquivo abre o SFTP do caminho atual. O botão da área de transferência copia o conteúdo quando o texto é selecionado e cola o conteúdo da área de transferência no terminal quando nenhum texto é selecionado e há conteúdo na área de transferência. O ícone de código cola trechos de código no terminal e os executa.';

View File

@@ -46,16 +46,20 @@ class AppLocalizationsRu extends AppLocalizations {
'Автоматическое обновление виджета на главном экране';
@override
String get backupTip =>
'Экспортированные данные могут быть зашифрованы паролем. \nПожалуйста, храните их в безопасности.';
String get backupEncrypted => 'Резервная копия зашифрована';
@override
String get backupVersionNotMatch =>
'Версия резервной копии не совпадает, восстановление невозможно';
String get backupNotEncrypted => 'Резервная копия не зашифрована';
@override
String get backupPassword => 'Пароль резервной копии';
@override
String get backupPasswordRemoved => 'Пароль резервной копии удален';
@override
String get backupPasswordSet => 'Пароль резервной копии установлен';
@override
String get backupPasswordTip =>
'Установите пароль для шифрования файлов резервных копий. Оставьте пустым, чтобы отключить шифрование.';
@@ -64,16 +68,12 @@ class AppLocalizationsRu extends AppLocalizations {
String get backupPasswordWrong => 'Неверный пароль резервной копии';
@override
String get backupEncrypted => 'Резервная копия зашифрована';
String get backupTip =>
'Экспортированные данные могут быть зашифрованы паролем. \nПожалуйста, храните их в безопасности.';
@override
String get backupNotEncrypted => 'Резервная копия не зашифрована';
@override
String get backupPasswordSet => 'Пароль резервной копии установлен';
@override
String get backupPasswordRemoved => 'Пароль резервной копии удален';
String get backupVersionNotMatch =>
'Версия резервной копии не совпадает, восстановление невозможно';
@override
String get battery => 'Батарея';
@@ -607,6 +607,60 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Затрачено времени: $time';
}
@override
String sshConfigAllExist(Object duplicateCount) {
return 'Все серверы уже существуют (найдено $duplicateCount дубликатов)';
}
@override
String sshConfigDuplicatesSkipped(Object duplicateCount) {
return '$duplicateCount дубликатов будут пропущены';
}
@override
String get sshConfigFound => 'Мы нашли SSH-конфигурацию в вашей системе';
@override
String sshConfigFoundServers(Object totalCount) {
return 'Найдено $totalCount серверов';
}
@override
String get sshConfigImport => 'Импорт SSH Конфигурации';
@override
String get sshConfigImportHelp =>
'Можно импортировать только базовую информацию, например: IP/порт.';
@override
String get sshConfigImportPermission =>
'Хотите ли вы дать разрешение на чтение ~/.ssh/config и автоматический импорт настроек сервера?';
@override
String get sshConfigImportTip =>
'Предложение прочитать ~/.ssh/config при создании первого сервера';
@override
String sshConfigImported(Object count) {
return 'Импортировано $count серверов из SSH-конфигурации';
}
@override
String get sshConfigManualSelect =>
'Хотели бы вы вручную выбрать файл конфигурации SSH?';
@override
String get sshConfigNoServers => 'Серверы не найдены в SSH-конфигурации';
@override
String get sshConfigPermissionDenied =>
'Невозможно получить доступ к файлу конфигурации SSH из-за разрешений macOS.';
@override
String sshConfigServersToImport(Object importCount) {
return '$importCount серверов будут импортированы';
}
@override
String get sshTermHelp =>
'Когда терминал можно прокручивать, горизонтальное перетаскивание позволяет выделить текст. Нажатие на кнопку клавиатуры включает/выключает клавиатуру. Иконка файла открывает текущий путь SFTP. Кнопка буфера обмена копирует содержимое, когда текст выделен, и вставляет содержимое из буфера обмена в терминал, когда текст не выделен, а в буфере есть содержимое. Иконка кода вставляет фрагменты кода в терминал и выполняет их.';

View File

@@ -45,15 +45,20 @@ class AppLocalizationsTr extends AppLocalizations {
String get autoUpdateHomeWidget => 'Ana ekran bileşenini otomatik güncelle';
@override
String get backupTip =>
'Dışa aktarılan veriler parola ile şifrelenebilir. \nLütfen güvenli bir şekilde saklayın.';
String get backupEncrypted => 'Yedekleme şifrelenmiş';
@override
String get backupVersionNotMatch => 'Yedekleme sürümü eşleşmiyor.';
String get backupNotEncrypted => 'Yedekleme şifreli değil';
@override
String get backupPassword => 'Yedekleme parolası';
@override
String get backupPasswordRemoved => 'Yedekleme parolası kaldırıldı';
@override
String get backupPasswordSet => 'Yedekleme parolası ayarlandı';
@override
String get backupPasswordTip =>
'Yedekleme dosyalarını şifrelemek için bir parola belirleyin. Şifrelemeyi devre dışı bırakmak için boş bırakın.';
@@ -62,16 +67,11 @@ class AppLocalizationsTr extends AppLocalizations {
String get backupPasswordWrong => 'Yanlış yedekleme parolası';
@override
String get backupEncrypted => 'Yedekleme şifrelenmiş';
String get backupTip =>
'Dışa aktarılan veriler parola ile şifrelenebilir. \nLütfen güvenli bir şekilde saklayın.';
@override
String get backupNotEncrypted => 'Yedekleme şifreli değil';
@override
String get backupPasswordSet => 'Yedekleme parolası ayarlandı';
@override
String get backupPasswordRemoved => 'Yedekleme parolası kaldırıldı';
String get backupVersionNotMatch => 'Yedekleme sürümü eşleşmiyor.';
@override
String get battery => 'Pil';
@@ -603,6 +603,60 @@ class AppLocalizationsTr extends AppLocalizations {
return 'Harcanan süre: $time';
}
@override
String sshConfigAllExist(Object duplicateCount) {
return 'Tüm sunucular zaten mevcut ($duplicateCount kopya bulundu)';
}
@override
String sshConfigDuplicatesSkipped(Object duplicateCount) {
return '$duplicateCount kopya atlanacak';
}
@override
String get sshConfigFound => 'Sisteminizde SSH yapılandırması bulduk';
@override
String sshConfigFoundServers(Object totalCount) {
return '$totalCount sunucu bulundu';
}
@override
String get sshConfigImport => 'SSH Yapılandırma İçe Aktarma';
@override
String get sshConfigImportHelp =>
'Yalnızca temel bilgiler içe aktarılabilir, örneğin: IP/Port.';
@override
String get sshConfigImportPermission =>
'~/.ssh/config dosyasını okumak ve sunucu ayarlarını otomatik olarak içe aktarmak için izin vermek ister misiniz?';
@override
String get sshConfigImportTip =>
'İlk sunucu oluşturulurken ~/.ssh/config okuma istemi';
@override
String sshConfigImported(Object count) {
return 'SSH yapılandırmasından $count sunucu içe aktarıldı';
}
@override
String get sshConfigManualSelect =>
'SSH yapılandırma dosyasını manuel olarak seçmek ister misiniz?';
@override
String get sshConfigNoServers => 'SSH yapılandırmasında sunucu bulunamadı';
@override
String get sshConfigPermissionDenied =>
'macOS izinleri nedeniyle SSH yapılandırma dosyasına erişilemiyor.';
@override
String sshConfigServersToImport(Object importCount) {
return '$importCount sunucu içe aktarılacak';
}
@override
String get sshTermHelp =>
'Terminal kaydırılabilir olduğunda, yatay olarak sürüklemek metni seçebilir. Klavye düğmesine tıklamak klavyeyi açar/kapar. Dosya simgesi mevcut yolu SFTP\'de açar. Pano düğmesi, metin seçiliyken içeriği kopyalar ve metin seçili değilken panoda içerik varsa terminale yapıştırır. Kod simgesi, kod parçacıklarını terminale yapıştırır ve yürütür.';

View File

@@ -46,16 +46,20 @@ class AppLocalizationsUk extends AppLocalizations {
'Автоматичне оновлення віджетів на головному екрані';
@override
String get backupTip =>
'Експортовані дані можуть бути зашифровані паролем. \nБудь ласка, зберігайте їх у безпеці.';
String get backupEncrypted => 'Резервна копія зашифрована';
@override
String get backupVersionNotMatch =>
'Версія резервного копіювання не збіглася.';
String get backupNotEncrypted => 'Резервна копія не зашифрована';
@override
String get backupPassword => 'Пароль резервного копіювання';
@override
String get backupPasswordRemoved => 'Пароль резервного копіювання видалено';
@override
String get backupPasswordSet => 'Пароль резервного копіювання встановлено';
@override
String get backupPasswordTip =>
'Встановіть пароль для шифрування файлів резервного копіювання. Залиште порожнім для відключення шифрування.';
@@ -64,16 +68,12 @@ class AppLocalizationsUk extends AppLocalizations {
String get backupPasswordWrong => 'Неправильний пароль резервного копіювання';
@override
String get backupEncrypted => 'Резервна копія зашифрована';
String get backupTip =>
'Експортовані дані можуть бути зашифровані паролем. \nБудь ласка, зберігайте їх у безпеці.';
@override
String get backupNotEncrypted => 'Резервна копія не зашифрована';
@override
String get backupPasswordSet => 'Пароль резервного копіювання встановлено';
@override
String get backupPasswordRemoved => 'Пароль резервного копіювання видалено';
String get backupVersionNotMatch =>
'Версія резервного копіювання не збіглася.';
@override
String get battery => 'Акумулятор';
@@ -608,6 +608,60 @@ class AppLocalizationsUk extends AppLocalizations {
return 'Витрачений час: $time';
}
@override
String sshConfigAllExist(Object duplicateCount) {
return 'Всі сервери вже існують (знайдено $duplicateCount дублікатів)';
}
@override
String sshConfigDuplicatesSkipped(Object duplicateCount) {
return '$duplicateCount дублікатів буде пропущено';
}
@override
String get sshConfigFound => 'Ми знайшли SSH-конфігурацію у вашій системі';
@override
String sshConfigFoundServers(Object totalCount) {
return 'Знайдено $totalCount серверів';
}
@override
String get sshConfigImport => 'Імпорт SSH Конфігурації';
@override
String get sshConfigImportHelp =>
'Можна імпортувати лише базову інформацію, наприклад: IP/порт.';
@override
String get sshConfigImportPermission =>
'Чи хочете ви надати дозвіл на читання ~/.ssh/config та автоматичний імпорт налаштувань сервера?';
@override
String get sshConfigImportTip =>
'Пропозиція прочитати ~/.ssh/config при створенні першого сервера';
@override
String sshConfigImported(Object count) {
return 'Імпортовано $count серверів з SSH-конфігурації';
}
@override
String get sshConfigManualSelect =>
'Чи хочете ви вручну вибрати файл конфігурації SSH?';
@override
String get sshConfigNoServers => 'Сервери не знайдені в SSH-конфігурації';
@override
String get sshConfigPermissionDenied =>
'Неможливо отримати доступ до файлу конфігурації SSH через дозволи macOS.';
@override
String sshConfigServersToImport(Object importCount) {
return '$importCount серверів буде імпортовано';
}
@override
String get sshTermHelp =>
'Коли термінал прокрутний, горизонтальне проведення вибирає текст. Натискання кнопки клавіатури вмикає/вимикає клавіатуру. Іконка файлу відкриває поточний шлях SFTP. Кнопка буфера обміну копіює вміст, коли текст вибрано, і вставляє вміст з буфера обміну в термінал, коли текст не вибрано і є вміст у буфері обміну. Іконка коду вставляє фрагменти коду в термінал і виконує їх.';

View File

@@ -42,14 +42,20 @@ class AppLocalizationsZh extends AppLocalizations {
String get autoUpdateHomeWidget => '自动更新桌面小部件';
@override
String get backupTip => '导出数据可通过密码加密,请妥善保管。';
String get backupEncrypted => '备份已加密';
@override
String get backupVersionNotMatch => '备份版本不兼容,无法恢复';
String get backupNotEncrypted => '备份未加密';
@override
String get backupPassword => '备份密码';
@override
String get backupPasswordRemoved => '备份密码已移除';
@override
String get backupPasswordSet => '备份密码已设置';
@override
String get backupPasswordTip => '设置密码以加密备份文件。留空则禁用加密。';
@@ -57,16 +63,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get backupPasswordWrong => '备份密码错误';
@override
String get backupEncrypted => '备份已加密';
String get backupTip => '导出数据可通过密码加密,请妥善保管。';
@override
String get backupNotEncrypted => '备份未加密';
@override
String get backupPasswordSet => '备份密码已设置';
@override
String get backupPasswordRemoved => '备份密码已移除';
String get backupVersionNotMatch => '备份版本不兼容,无法恢复';
@override
String get battery => '电池';
@@ -578,6 +578,55 @@ class AppLocalizationsZh extends AppLocalizations {
return '耗时:$time';
}
@override
String sshConfigAllExist(Object duplicateCount) {
return '所有服务器已存在(发现 $duplicateCount 个重复项)';
}
@override
String sshConfigDuplicatesSkipped(Object duplicateCount) {
return '$duplicateCount 个重复项将被跳过';
}
@override
String get sshConfigFound => '我们在您的系统中发现了 SSH 配置。';
@override
String sshConfigFoundServers(Object totalCount) {
return '发现 $totalCount 个服务器';
}
@override
String get sshConfigImport => 'SSH 配置导入';
@override
String get sshConfigImportHelp => '只能导入基础信息例如IP/端口';
@override
String get sshConfigImportPermission => '是否允许读取 ~/.ssh/config 并自动导入服务器设置?';
@override
String get sshConfigImportTip => '首次创建服务器时提示读取 ~/.ssh/config';
@override
String sshConfigImported(Object count) {
return '从 SSH 配置导入了 $count 个服务器';
}
@override
String get sshConfigManualSelect => '是否要手动选择 SSH 配置文件?';
@override
String get sshConfigNoServers => 'SSH 配置中未找到服务器';
@override
String get sshConfigPermissionDenied => '由于 macOS 权限限制,无法访问 SSH 配置文件。';
@override
String sshConfigServersToImport(Object importCount) {
return '$importCount 个服务器将被导入';
}
@override
String get sshTermHelp =>
'在终端可滚动时,横向拖动可以选中文字。点击键盘按钮可以开启/关闭键盘。文件图标会打开当前路径 SFTP。剪切板按钮会在有选中文字时复制内容在未选中并且剪切板有内容时粘贴内容到终端。代码图标会粘贴代码片段到终端并执行。';
@@ -794,14 +843,20 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get autoUpdateHomeWidget => '自動更新桌面小工具';
@override
String get backupTip => '匯出的資料可透過密碼加密,請妥善保管。';
String get backupEncrypted => '備份已加密';
@override
String get backupVersionNotMatch => '備份版本不相容,無法還原';
String get backupNotEncrypted => '備份未加密';
@override
String get backupPassword => '備份密碼';
@override
String get backupPasswordRemoved => '備份密碼已移除';
@override
String get backupPasswordSet => '備份密碼已設定';
@override
String get backupPasswordTip => '設定密碼來加密備份檔案。留空則停用加密。';
@@ -809,16 +864,10 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get backupPasswordWrong => '備份密碼錯誤';
@override
String get backupEncrypted => '備份已加密';
String get backupTip => '匯出的資料可透過密碼加密,請妥善保管。';
@override
String get backupNotEncrypted => '備份未加密';
@override
String get backupPasswordSet => '備份密碼已設定';
@override
String get backupPasswordRemoved => '備份密碼已移除';
String get backupVersionNotMatch => '備份版本不相容,無法還原';
@override
String get battery => '電池';
@@ -1330,6 +1379,55 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
return '耗時:$time';
}
@override
String sshConfigAllExist(Object duplicateCount) {
return '所有伺服器均已存在(發現$duplicateCount個重複項';
}
@override
String sshConfigDuplicatesSkipped(Object duplicateCount) {
return '將跳過$duplicateCount個重複項';
}
@override
String get sshConfigFound => '我們在您的系統中發現了SSH設定';
@override
String sshConfigFoundServers(Object totalCount) {
return '發現$totalCount個伺服器';
}
@override
String get sshConfigImport => '匯入SSH設定';
@override
String get sshConfigImportHelp => '只能匯入基礎資訊例如IP/端口。';
@override
String get sshConfigImportPermission => '您是否希望允許讀取 ~/.ssh/config 並自動匯入伺服器設定?';
@override
String get sshConfigImportTip => '在建立第一個伺服器時提示讀取 ~/.ssh/config';
@override
String sshConfigImported(Object count) {
return '已從SSH設定匯入$count個伺服器';
}
@override
String get sshConfigManualSelect => '是否要手動選擇 SSH 設定檔案?';
@override
String get sshConfigNoServers => 'SSH設定中未找到伺服器';
@override
String get sshConfigPermissionDenied => '由於 macOS 權限限制,無法存取 SSH 設定檔案。';
@override
String sshConfigServersToImport(Object importCount) {
return '將匯入$importCount個伺服器';
}
@override
String get sshTermHelp =>
'在終端機可捲動時,橫向拖動可以選中文字。點擊鍵盤按鈕可以開啟/關閉鍵盤。檔案圖示會打開目前路徑 SFTP。剪貼簿按鈕會在有選中文字時複製內容在未選中並且剪貼簿有內容時貼上內容到終端機。程式碼圖示會貼上程式碼片段到終端機並執行。';

View File

@@ -11,15 +11,15 @@
"autoConnect": "Automatisch verbinden",
"autoRun": "Automatischer Start",
"autoUpdateHomeWidget": "Home-Widget automatisch aktualisieren",
"backupTip": "Die exportierten Daten können mit einem Passwort verschlüsselt werden. \nBitte sicher aufbewahren.",
"backupVersionNotMatch": "Die Backup-Version stimmt nicht überein.",
"backupPassword": "Backup-Passwort",
"backupPasswordTip": "Setzen Sie ein Passwort, um Backup-Dateien zu verschlüsseln. Leer lassen, um Verschlüsselung zu deaktivieren.",
"backupPasswordWrong": "Falsches Backup-Passwort",
"backupEncrypted": "Backup ist verschlüsselt",
"backupNotEncrypted": "Backup ist nicht verschlüsselt",
"backupPasswordSet": "Backup-Passwort gesetzt",
"backupPassword": "Backup-Passwort",
"backupPasswordRemoved": "Backup-Passwort entfernt",
"backupPasswordSet": "Backup-Passwort gesetzt",
"backupPasswordTip": "Setzen Sie ein Passwort, um Backup-Dateien zu verschlüsseln. Leer lassen, um Verschlüsselung zu deaktivieren.",
"backupPasswordWrong": "Falsches Backup-Passwort",
"backupTip": "Die exportierten Daten können mit einem Passwort verschlüsselt werden. \nBitte sicher aufbewahren.",
"backupVersionNotMatch": "Die Backup-Version stimmt nicht überein.",
"battery": "Batterie",
"bgRun": "Hintergrundaktualisierung",
"bgRunTip": "Dieser Schalter bedeutet nur, dass die App versuchen wird, im Hintergrund zu laufen. Ob sie im Hintergrund laufen kann, hängt davon ab, ob die Berechtigungen aktiviert sind oder nicht. Bei nativem Android deaktivieren Sie bitte \"Batterieoptimierung\" in dieser App, und bei miui ändern Sie bitte die Energiesparrichtlinie auf \"Unbegrenzt\".",
@@ -180,6 +180,19 @@
"specifyDevTip": "Zum Beispiel bezieht sich die Standard-Netzwerkverkehrsstatistik auf alle Geräte. Hier können Sie ein bestimmtes Gerät angeben.",
"speed": "Tempo",
"spentTime": "Benötigte Zeit: {time}",
"sshConfigAllExist": "Alle Server existieren bereits ({duplicateCount} Duplikate gefunden)",
"sshConfigDuplicatesSkipped": "{duplicateCount} Duplikate werden übersprungen",
"sshConfigFound": "Wir haben SSH-Konfiguration auf Ihrem System gefunden.",
"sshConfigFoundServers": "{totalCount} Server gefunden",
"sshConfigImport": "SSH-Konfiguration importieren",
"sshConfigImportHelp": "Es können nur Basisinformationen importiert werden, zum Beispiel: IP/Port.",
"sshConfigImportPermission": "Möchten Sie die Berechtigung erteilen, ~/.ssh/config zu lesen und Server-Einstellungen automatisch zu importieren?",
"sshConfigImportTip": "Bei der ersten Server-Erstellung zum Lesen von ~/.ssh/config auffordern",
"sshConfigImported": "{count} Server aus SSH-Konfiguration importiert",
"sshConfigManualSelect": "Möchten Sie die SSH-Konfigurationsdatei manuell auswählen?",
"sshConfigNoServers": "Keine Server in der SSH-Konfiguration gefunden",
"sshConfigPermissionDenied": "Aufgrund der macOS-Berechtigungen kann nicht auf die SSH-Konfigurationsdatei zugegriffen werden.",
"sshConfigServersToImport": "{importCount} Server werden importiert",
"sshTermHelp": "Wenn das Terminal scrollbar ist, kann durch horizontales Ziehen Text ausgewählt werden. Durch Klicken auf die Tastentaste wird die Tastatur ein- oder ausgeschaltet. Das Dateisymbol öffnet den aktuellen Pfad SFTP. Die Zwischenablage-Schaltfläche kopiert den Inhalt, wenn Text ausgewählt ist, und fügt Inhalte aus der Zwischenablage in das Terminal ein, wenn kein Text ausgewählt ist und Inhalte in der Zwischenablage vorhanden sind. Das Codesymbol fügt Code-Schnipsel ins Terminal ein und führt sie aus.",
"sshTip": "Diese Funktion befindet sich jetzt in der Experimentierphase.\n\nBitte melde Bugs auf {url} oder mach mit bei der Entwicklung.",
"sshVirtualKeyAutoOff": "Automatische Umschaltung der virtuellen Tasten",

View File

@@ -11,15 +11,15 @@
"autoConnect": "Auto connect",
"autoRun": "Auto run",
"autoUpdateHomeWidget": "Automatic home widget update",
"backupTip": "The exported data can be encrypted with password. \nPlease keep it safe.",
"backupVersionNotMatch": "Backup version is not match.",
"backupPassword": "Backup password",
"backupPasswordTip": "Set a password to encrypt backup files. Leave empty to disable encryption.",
"backupPasswordWrong": "Incorrect backup password",
"backupEncrypted": "Backup is encrypted",
"backupNotEncrypted": "Backup is not encrypted",
"backupPasswordSet": "Backup password set",
"backupPassword": "Backup password",
"backupPasswordRemoved": "Backup password removed",
"backupPasswordSet": "Backup password set",
"backupPasswordTip": "Set a password to encrypt backup files. Leave empty to disable encryption.",
"backupPasswordWrong": "Incorrect backup password",
"backupTip": "The exported data can be encrypted with password. \nPlease keep it safe.",
"backupVersionNotMatch": "Backup version is not match.",
"battery": "Battery",
"bgRun": "Run in background",
"bgRunTip": "This switch only means the program will try to run in the background. Whether it can run in the background depends on whether the permission is enabled or not. For AOSP-based Android ROMs, please disable \"Battery Optimization\" in this app. For MIUI / HyperOS, please change the power saving policy to \"Unlimited\".",
@@ -180,6 +180,19 @@
"specifyDevTip": "For example, network traffic statistics are by default for all devices. You can specify a particular device here.",
"speed": "Speed",
"spentTime": "Spent time: {time}",
"sshConfigAllExist": "All servers already exist ({duplicateCount} duplicates found)",
"sshConfigDuplicatesSkipped": "{duplicateCount} duplicates will be skipped",
"sshConfigFound": "We found SSH configuration on your system.",
"sshConfigFoundServers": "Found {totalCount} servers",
"sshConfigImport": "SSH Config Import",
"sshConfigImportHelp": "Only basic information can be imported, for example: IP/Port.",
"sshConfigImportPermission": "Would you like to give permission to read ~/.ssh/config and automatically import server settings?",
"sshConfigImportTip": "Prompt to read ~/.ssh/config on first server creation",
"sshConfigImported": "Imported {count} servers from SSH config",
"sshConfigManualSelect": "Would you like to select the SSH config file manually?",
"sshConfigNoServers": "No servers found in SSH config",
"sshConfigPermissionDenied": "Cannot access SSH config file due to macOS permissions.",
"sshConfigServersToImport": "{importCount} servers will be imported",
"sshTermHelp": "When the terminal is scrollable, dragging horizontally can select text. Clicking the keyboard button turns the keyboard on/off. The file icon opens the current path SFTP. The clipboard button copies the content when text is selected, and pastes content from the clipboard into the terminal when no text is selected and there is content on the clipboard. The code icon pastes code snippets into the terminal and executes them.",
"sshTip": "This function is now in the experimental stage.\n\nPlease report bugs on {url} or join our development.",
"sshVirtualKeyAutoOff": "Auto switching of virtual keys",

View File

@@ -11,15 +11,15 @@
"autoConnect": "Conexión automática",
"autoRun": "Ejecución automática",
"autoUpdateHomeWidget": "Actualizar automáticamente el widget del escritorio",
"backupTip": "Los datos exportados pueden ser encriptados con contraseña. \nPor favor guárdalos en un lugar seguro.",
"backupVersionNotMatch": "La versión de la copia de seguridad no coincide, no se puede restaurar",
"backupPassword": "Contraseña de respaldo",
"backupPasswordTip": "Establece una contraseña para encriptar archivos de respaldo. Déjalo vacío para desactivar la encriptación.",
"backupPasswordWrong": "Contraseña de respaldo incorrecta",
"backupEncrypted": "El respaldo está encriptado",
"backupNotEncrypted": "El respaldo no está encriptado",
"backupPasswordSet": "Contraseña de respaldo establecida",
"backupPassword": "Contraseña de respaldo",
"backupPasswordRemoved": "Contraseña de respaldo eliminada",
"backupPasswordSet": "Contraseña de respaldo establecida",
"backupPasswordTip": "Establece una contraseña para encriptar archivos de respaldo. Déjalo vacío para desactivar la encriptación.",
"backupPasswordWrong": "Contraseña de respaldo incorrecta",
"backupTip": "Los datos exportados pueden ser encriptados con contraseña. \nPor favor guárdalos en un lugar seguro.",
"backupVersionNotMatch": "La versión de la copia de seguridad no coincide, no se puede restaurar",
"battery": "Batería",
"bgRun": "Ejecución en segundo plano",
"bgRunTip": "Este interruptor solo indica que la aplicación intentará correr en segundo plano, si puede hacerlo o no depende de si tiene el permiso correspondiente. En Android puro, por favor desactiva la “optimización de batería” para esta app, en MIUI por favor cambia la estrategia de ahorro de energía a “Sin restricciones”.",
@@ -180,6 +180,19 @@
"specifyDevTip": "Por ejemplo, las estadísticas de tráfico de red son por defecto para todos los dispositivos. Aquí puede especificar un dispositivo en particular.",
"speed": "Velocidad",
"spentTime": "Tiempo gastado: {time}",
"sshConfigAllExist": "Todos los servidores ya existen (se encontraron {duplicateCount} duplicados)",
"sshConfigDuplicatesSkipped": "Se omitirán {duplicateCount} duplicados",
"sshConfigFound": "Encontramos configuración SSH en tu sistema",
"sshConfigFoundServers": "Se encontraron {totalCount} servidores",
"sshConfigImport": "Importar Configuración SSH",
"sshConfigImportHelp": "Solo se pueden importar datos básicos, por ejemplo: IP/Puerto.",
"sshConfigImportPermission": "¿Te gustaría dar permiso para leer ~/.ssh/config e importar automáticamente la configuración de servidores?",
"sshConfigImportTip": "Sugerencia para leer ~/.ssh/config al crear el primer servidor",
"sshConfigImported": "Se importaron {count} servidores desde la configuración SSH",
"sshConfigManualSelect": "¿Te gustaría seleccionar manualmente el archivo de configuración SSH?",
"sshConfigNoServers": "No se encontraron servidores en la configuración SSH",
"sshConfigPermissionDenied": "No se puede acceder al archivo de configuración SSH debido a los permisos de macOS.",
"sshConfigServersToImport": "Se importarán {importCount} servidores",
"sshTermHelp": "Cuando el terminal es desplazable, arrastrar horizontalmente puede seleccionar texto. Hacer clic en el botón del teclado enciende/apaga el teclado. El icono de archivo abre el SFTP de la ruta actual. El botón del portapapeles copia el contenido cuando se selecciona texto y pega el contenido del portapapeles en el terminal cuando no se selecciona texto y hay contenido en el portapapeles. El icono de código pega fragmentos de código en el terminal y los ejecuta.",
"sshTip": "Esta función está en fase de pruebas.\n\nPor favor, informa los problemas en {url}, o únete a nuestro desarrollo.",
"sshVirtualKeyAutoOff": "Desactivación automática de teclas virtuales",

View File

@@ -11,15 +11,15 @@
"autoConnect": "Connexion automatique",
"autoRun": "Exécution automatique",
"autoUpdateHomeWidget": "Mise à jour automatique du widget d'accueil",
"backupTip": "Les données exportées peuvent être chiffrées avec un mot de passe. \nVeuillez les garder en sécurité.",
"backupVersionNotMatch": "La version de sauvegarde ne correspond pas.",
"backupPassword": "Mot de passe de sauvegarde",
"backupPasswordTip": "Définissez un mot de passe pour chiffrer les fichiers de sauvegarde. Laissez vide pour désactiver le chiffrement.",
"backupPasswordWrong": "Mot de passe de sauvegarde incorrect",
"backupEncrypted": "La sauvegarde est chiffrée",
"backupNotEncrypted": "La sauvegarde n'est pas chiffrée",
"backupPasswordSet": "Mot de passe de sauvegarde défini",
"backupPassword": "Mot de passe de sauvegarde",
"backupPasswordRemoved": "Mot de passe de sauvegarde supprimé",
"backupPasswordSet": "Mot de passe de sauvegarde défini",
"backupPasswordTip": "Définissez un mot de passe pour chiffrer les fichiers de sauvegarde. Laissez vide pour désactiver le chiffrement.",
"backupPasswordWrong": "Mot de passe de sauvegarde incorrect",
"backupTip": "Les données exportées peuvent être chiffrées avec un mot de passe. \nVeuillez les garder en sécurité.",
"backupVersionNotMatch": "La version de sauvegarde ne correspond pas.",
"battery": "Batterie",
"bgRun": "Exécution en arrière-plan",
"bgRunTip": "Cette option signifie seulement que le programme essaiera de s'exécuter en arrière-plan, que cela soit possible dépend de l'autorisation activée ou non. Pour Android natif, veuillez désactiver l'« Optimisation de la batterie » dans cette application, et pour MIUI, veuillez changer la politique d'économie d'énergie en « Illimité ».",
@@ -180,6 +180,19 @@
"specifyDevTip": "Par exemple, les statistiques de trafic réseau concernent par défaut tous les appareils. Vous pouvez spécifier ici un appareil particulier.",
"speed": "Vitesse",
"spentTime": "Temps écoulé : {time}",
"sshConfigAllExist": "Tous les serveurs existent déjà ({duplicateCount} doublons trouvés)",
"sshConfigDuplicatesSkipped": "{duplicateCount} doublons seront ignorés",
"sshConfigFound": "Nous avons trouvé une configuration SSH sur votre système.",
"sshConfigFoundServers": "{totalCount} serveurs trouvés",
"sshConfigImport": "Importation de configuration SSH",
"sshConfigImportHelp": "Seules les informations de base peuvent être importées, par exemple : IP/Port.",
"sshConfigImportPermission": "Souhaitez-vous donner la permission de lire ~/.ssh/config et d'importer automatiquement les paramètres du serveur ?",
"sshConfigImportTip": "Proposer de lire ~/.ssh/config lors de la première création de serveur",
"sshConfigImported": "{count} serveurs importés depuis la configuration SSH",
"sshConfigManualSelect": "Souhaitez-vous sélectionner manuellement le fichier de configuration SSH ?",
"sshConfigNoServers": "Aucun serveur trouvé dans la configuration SSH",
"sshConfigPermissionDenied": "Impossible d'accéder au fichier de configuration SSH en raison des permissions macOS.",
"sshConfigServersToImport": "{importCount} serveurs seront importés",
"sshTermHelp": "Lorsque le terminal est défilable, faire glisser horizontalement permet de sélectionner du texte. En cliquant sur le bouton du clavier, vous activez/désactivez le clavier. L'icône de fichier ouvre le chemin actuel SFTP. Le bouton du presse-papiers copie le contenu lorsque du texte est sélectionné, et colle le contenu du presse-papiers dans le terminal lorsqu'aucun texte n'est sélectionné et qu'il y a du contenu dans le presse-papiers. L'icône de code colle des extraits de code dans le terminal et les exécute.",
"sshTip": "Cette fonctionnalité est actuellement à l'étape expérimentale.\n\nVeuillez signaler les bugs sur {url} ou rejoindre notre développement.",
"sshVirtualKeyAutoOff": "Activation automatique des touches virtuelles",
@@ -236,5 +249,5 @@
"wolTip": "Après avoir configuré le WOL (Wake-on-LAN), une requête WOL est envoyée chaque fois que le serveur est connecté.",
"write": "Écrire",
"writeScriptFailTip": "Échec de l'écriture dans le script, probablement en raison d'un manque de permissions ou que le répertoire n'existe pas.",
"writeScriptTip": "Après la connexion au serveur, un script sera écrit dans `~/.config/server_box` \n | `/tmp/server_box` pour surveiller létat du système. Vous pouvez examiner le contenu du script."
"writeScriptTip": "Après la connexion au serveur, un script sera écrit dans `~/.config/server_box` \n | `/tmp/server_box` pour surveiller l'état du système. Vous pouvez examiner le contenu du script."
}

View File

@@ -11,15 +11,15 @@
"autoConnect": "Hubungkan otomatis",
"autoRun": "Berjalan Otomatis",
"autoUpdateHomeWidget": "Widget Rumah Pembaruan Otomatis",
"backupTip": "Data yang diekspor dapat dienkripsi dengan kata sandi. \nHarap jaga keamanannya.",
"backupVersionNotMatch": "Versi cadangan tidak cocok.",
"backupPassword": "Kata sandi cadangan",
"backupPasswordTip": "Setel kata sandi untuk mengenkripsi file cadangan. Biarkan kosong untuk menonaktifkan enkripsi.",
"backupPasswordWrong": "Kata sandi cadangan salah",
"backupEncrypted": "Cadangan telah dienkripsi",
"backupNotEncrypted": "Cadangan tidak dienkripsi",
"backupPasswordSet": "Kata sandi cadangan ditetapkan",
"backupPassword": "Kata sandi cadangan",
"backupPasswordRemoved": "Kata sandi cadangan dihapus",
"backupPasswordSet": "Kata sandi cadangan ditetapkan",
"backupPasswordTip": "Setel kata sandi untuk mengenkripsi file cadangan. Biarkan kosong untuk menonaktifkan enkripsi.",
"backupPasswordWrong": "Kata sandi cadangan salah",
"backupTip": "Data yang diekspor dapat dienkripsi dengan kata sandi. \nHarap jaga keamanannya.",
"backupVersionNotMatch": "Versi cadangan tidak cocok.",
"battery": "Baterai",
"bgRun": "Jalankan di Backgroud",
"bgRunTip": "Sakelar ini hanya berarti aplikasi akan mencoba berjalan di latar belakang, apakah aplikasi dapat berjalan di latar belakang tergantung pada apakah izin diaktifkan atau tidak. Untuk Android asli, nonaktifkan \"Pengoptimalan Baterai\" di aplikasi ini, dan untuk miui, ubah kebijakan penghematan daya ke \"Tidak Terbatas\".",
@@ -180,6 +180,19 @@
"specifyDevTip": "Misalnya, statistik lalu lintas jaringan secara default adalah untuk semua perangkat. Anda dapat menentukan perangkat tertentu di sini.",
"speed": "Kecepatan",
"spentTime": "Menghabiskan waktu: {time}",
"sshConfigAllExist": "Semua server sudah ada (ditemukan {duplicateCount} duplikat)",
"sshConfigDuplicatesSkipped": "{duplicateCount} duplikat akan dilewati",
"sshConfigFound": "Kami menemukan konfigurasi SSH di sistem Anda",
"sshConfigFoundServers": "Ditemukan {totalCount} server",
"sshConfigImport": "Impor Konfigurasi SSH",
"sshConfigImportHelp": "Hanya informasi dasar yang dapat diimpor, misalnya: IP/Port.",
"sshConfigImportPermission": "Apakah Anda ingin memberikan izin untuk membaca ~/.ssh/config dan secara otomatis mengimpor pengaturan server?",
"sshConfigImportTip": "Prompt untuk membaca ~/.ssh/config saat pembuatan server pertama",
"sshConfigImported": "Berhasil mengimpor {count} server dari konfigurasi SSH",
"sshConfigManualSelect": "Apakah Anda ingin memilih file konfigurasi SSH secara manual?",
"sshConfigNoServers": "Tidak ada server yang ditemukan dalam konfigurasi SSH",
"sshConfigPermissionDenied": "Tidak dapat mengakses file konfigurasi SSH karena izin macOS.",
"sshConfigServersToImport": "{importCount} server akan diimpor",
"sshTermHelp": "Ketika terminal dapat digulirkan, menggeser secara horizontal dapat memilih teks. Mengklik tombol keyboard mengaktifkan/menonaktifkan keyboard. Ikon file membuka SFTP jalur saat ini. Tombol papan klip menyalin konten saat teks dipilih, dan menempelkan konten dari papan klip ke terminal saat tidak ada teks yang dipilih dan ada konten di papan klip. Ikon kode menempelkan potongan kode ke terminal dan mengeksekusinya.",
"sshTip": "Fungsi ini sekarang dalam tahap eksperimen.\n\nHarap laporkan bug di {url} atau bergabunglah dengan pengembangan kami.",
"sshVirtualKeyAutoOff": "Switching Otomatis Kunci Virtual",

View File

@@ -11,15 +11,15 @@
"autoConnect": "自動接続",
"autoRun": "自動実行",
"autoUpdateHomeWidget": "ホームウィジェットを自動更新",
"backupTip": "エクスポートされたデータはパスワードで暗号化できます。 \n適切に保管してください。",
"backupVersionNotMatch": "バックアップバージョンが一致しないため、復元できません",
"backupPassword": "バックアップパスワード",
"backupPasswordTip": "バックアップファイルを暗号化するためのパスワードを設定してください。暗号化を無効にするには空白のままにしてください。",
"backupPasswordWrong": "バックアップパスワードが間違っています",
"backupEncrypted": "バックアップは暗号化されています",
"backupNotEncrypted": "バックアップは暗号化されていません",
"backupPasswordSet": "バックアップパスワードが設定されました",
"backupPassword": "バックアップパスワード",
"backupPasswordRemoved": "バックアップパスワードが削除されました",
"backupPasswordSet": "バックアップパスワードが設定されました",
"backupPasswordTip": "バックアップファイルを暗号化するためのパスワードを設定してください。暗号化を無効にするには空白のままにしてください。",
"backupPasswordWrong": "バックアップパスワードが間違っています",
"backupTip": "エクスポートされたデータはパスワードで暗号化できます。 \n適切に保管してください。",
"backupVersionNotMatch": "バックアップバージョンが一致しないため、復元できません",
"battery": "バッテリー",
"bgRun": "バックグラウンド実行",
"bgRunTip": "このスイッチはプログラムがバックグラウンドで実行を試みることを意味しますが、実際にバックグラウンドで実行できるかどうかは、権限が有効になっているかに依存します。AOSPベースのAndroid ROMでは、このアプリの「バッテリー最適化」をオフにしてください。MIUIでは、省エネモードを「無制限」に変更してください。",
@@ -180,6 +180,19 @@
"specifyDevTip": "例えば、ネットワークトラフィック統計はデフォルトですべてのデバイスに対するものです。ここで特定のデバイスを指定できます。",
"speed": "速度",
"spentTime": "費した時間: {time}",
"sshConfigAllExist": "すべてのサーバーがすでに存在します({duplicateCount}個の重複が見つかりました)",
"sshConfigDuplicatesSkipped": "{duplicateCount}個の重複がスキップされます",
"sshConfigFound": "システムにSSH設定が見つかりました。",
"sshConfigFoundServers": "{totalCount}個のサーバーが見つかりました",
"sshConfigImport": "SSH設定のインポート",
"sshConfigImportHelp": "インポートできるのは基本情報のみです。例IP/ポート。",
"sshConfigImportPermission": "~/.ssh/configを読み取ってサーバー設定を自動的にインポートする権限を与えますか",
"sshConfigImportTip": "初回サーバー作成時に~/.ssh/configの読み取りを促す",
"sshConfigImported": "SSH設定から{count}個のサーバーをインポートしました",
"sshConfigManualSelect": "SSH設定ファイルを手動で選択しますか",
"sshConfigNoServers": "SSH設定でサーバーが見つかりませんでした",
"sshConfigPermissionDenied": "macOSの権限により、SSH設定ファイルにアクセスできません。",
"sshConfigServersToImport": "{importCount}個のサーバーがインポートされます",
"sshTermHelp": "ターミナルがスクロール可能な場合、横にドラッグするとテキストを選択できます。キーボードボタンをクリックするとキーボードのオン/オフが切り替わります。ファイルアイコンは現在のパスSFTPを開きます。クリップボードボタンは、テキストが選択されているときに内容をコピーし、テキストが選択されておらずクリップボードに内容がある場合には、その内容をターミナルに貼り付けます。コードアイコンは、コードスニペットをターミナルに貼り付けて実行します。",
"sshTip": "この機能は現在テスト段階にあります。\n\n問題がある場合は、{url}でフィードバックしてください。",
"sshVirtualKeyAutoOff": "仮想キーの自動オフ",

View File

@@ -11,15 +11,15 @@
"autoConnect": "Automatisch verbinden",
"autoRun": "Automatisch uitvoeren",
"autoUpdateHomeWidget": "Automatische update van home-widget",
"backupTip": "De geëxporteerde gegevens kunnen worden versleuteld met een wachtwoord. \nBewaar deze aub veilig.",
"backupVersionNotMatch": "Back-upversie komt niet overeen.",
"backupPassword": "Back-up wachtwoord",
"backupPasswordTip": "Stel een wachtwoord in om back-upbestanden te versleutelen. Laat leeg om versleuteling uit te schakelen.",
"backupPasswordWrong": "Onjuist back-up wachtwoord",
"backupEncrypted": "Back-up is versleuteld",
"backupNotEncrypted": "Back-up is niet versleuteld",
"backupPasswordSet": "Back-up wachtwoord ingesteld",
"backupPassword": "Back-up wachtwoord",
"backupPasswordRemoved": "Back-up wachtwoord verwijderd",
"backupPasswordSet": "Back-up wachtwoord ingesteld",
"backupPasswordTip": "Stel een wachtwoord in om back-upbestanden te versleutelen. Laat leeg om versleuteling uit te schakelen.",
"backupPasswordWrong": "Onjuist back-up wachtwoord",
"backupTip": "De geëxporteerde gegevens kunnen worden versleuteld met een wachtwoord. \nBewaar deze aub veilig.",
"backupVersionNotMatch": "Back-upversie komt niet overeen.",
"battery": "Batterij",
"bgRun": "Uitvoeren op de achtergrond",
"bgRunTip": "Deze schakelaar betekent alleen dat het programma zal proberen op de achtergrond uit te voeren, of het in de achtergrond kan worden uitgevoerd, hangt af van of de toestemming is ingeschakeld of niet. Voor native Android, schakel \"Batterijoptimalisatie\" uit in deze app, en voor miui, wijzig de energiebesparingsbeleid naar \"Onbeperkt\".",
@@ -180,6 +180,19 @@
"specifyDevTip": "Bijvoorbeeld, netwerkverkeersstatistieken zijn standaard voor alle apparaten. Hier kunt u een specifiek apparaat opgeven.",
"speed": "Snelheid",
"spentTime": "Gebruikte tijd: {time}",
"sshConfigAllExist": "Alle servers bestaan al ({duplicateCount} duplicaten gevonden)",
"sshConfigDuplicatesSkipped": "{duplicateCount} duplicaten worden overgeslagen",
"sshConfigFound": "We hebben SSH-configuratie op uw systeem gevonden",
"sshConfigFoundServers": "{totalCount} servers gevonden",
"sshConfigImport": "SSH Configuratie Importeren",
"sshConfigImportHelp": "Alleen basisinformatie kan worden geïmporteerd, bijvoorbeeld: IP/Poort.",
"sshConfigImportPermission": "Wilt u toestemming geven om ~/.ssh/config te lezen en automatisch serverinstellingen te importeren?",
"sshConfigImportTip": "Prompt om ~/.ssh/config te lezen bij het aanmaken van de eerste server",
"sshConfigImported": "{count} servers geïmporteerd uit SSH-configuratie",
"sshConfigManualSelect": "Wilt u het SSH-configuratiebestand handmatig selecteren?",
"sshConfigNoServers": "Geen servers gevonden in SSH-configuratie",
"sshConfigPermissionDenied": "Kan geen toegang krijgen tot SSH-configuratiebestand vanwege macOS-rechten.",
"sshConfigServersToImport": "{importCount} servers worden geïmporteerd",
"sshTermHelp": "Wanneer het terminal scrollbaar is, kan horizontaal slepen tekst selecteren. Klikken op de toetsenbordknop schakelt het toetsenbord aan/uit. Het bestandsicoon opent de huidige pad SFTP. De klembordknop kopieert de inhoud wanneer tekst is geselecteerd en plakt inhoud van het klembord in de terminal wanneer geen tekst is geselecteerd en er inhoud op het klembord staat. Het code-icoon plakt codefragmenten in de terminal en voert ze uit.",
"sshTip": "Deze functie bevindt zich momenteel in de experimentele fase.\n\nMeld alstublieft bugs op {url} of sluit je aan bij onze ontwikkeling.",
"sshVirtualKeyAutoOff": "Automatisch schakelen van virtuele toetsen",

View File

@@ -11,15 +11,15 @@
"autoConnect": "Conexão automática",
"autoRun": "Execução automática",
"autoUpdateHomeWidget": "Atualização automática do widget da tela inicial",
"backupTip": "Os dados exportados podem ser criptografados com senha. \nPor favor, guarde-os com segurança.",
"backupVersionNotMatch": "Versão de backup não compatível, não é possível restaurar",
"backupPassword": "Senha de backup",
"backupPasswordTip": "Defina uma senha para criptografar arquivos de backup. Deixe vazio para desabilitar a criptografia.",
"backupPasswordWrong": "Senha de backup incorreta",
"backupEncrypted": "Backup está criptografado",
"backupNotEncrypted": "Backup não está criptografado",
"backupPasswordSet": "Senha de backup definida",
"backupPassword": "Senha de backup",
"backupPasswordRemoved": "Senha de backup removida",
"backupPasswordSet": "Senha de backup definida",
"backupPasswordTip": "Defina uma senha para criptografar arquivos de backup. Deixe vazio para desabilitar a criptografia.",
"backupPasswordWrong": "Senha de backup incorreta",
"backupTip": "Os dados exportados podem ser criptografados com senha. \nPor favor, guarde-os com segurança.",
"backupVersionNotMatch": "Versão de backup não compatível, não é possível restaurar",
"battery": "Bateria",
"bgRun": "Execução em segundo plano",
"bgRunTip": "Este interruptor indica que o programa tentará rodar em segundo plano, mas a capacidade de fazer isso depende das permissões concedidas. No Android nativo, desative a 'Otimização de bateria' para este app, no MIUI, altere a estratégia de economia de energia para 'Sem restrições'.",
@@ -180,6 +180,19 @@
"specifyDevTip": "Por exemplo, as estatísticas de tráfego de rede são por padrão para todos os dispositivos. Você pode especificar um dispositivo específico aqui.",
"speed": "Velocidade",
"spentTime": "Tempo gasto: {time}",
"sshConfigAllExist": "Todos os servidores já existem (encontradas {duplicateCount} duplicatas)",
"sshConfigDuplicatesSkipped": "{duplicateCount} duplicatas serão ignoradas",
"sshConfigFound": "Encontramos configuração SSH no seu sistema",
"sshConfigFoundServers": "Encontrados {totalCount} servidores",
"sshConfigImport": "Importar Configuração SSH",
"sshConfigImportHelp": "Só é possível importar informações básicas, por exemplo: IP/Porta.",
"sshConfigImportPermission": "Gostaria de dar permissão para ler ~/.ssh/config e importar automaticamente as configurações do servidor?",
"sshConfigImportTip": "Sugestão para ler ~/.ssh/config na criação do primeiro servidor",
"sshConfigImported": "Importados {count} servidores da configuração SSH",
"sshConfigManualSelect": "Gostaria de selecionar manualmente o arquivo de configuração SSH?",
"sshConfigNoServers": "Nenhum servidor encontrado na configuração SSH",
"sshConfigPermissionDenied": "Não é possível acessar o arquivo de configuração SSH devido às permissões do macOS.",
"sshConfigServersToImport": "{importCount} servidores serão importados",
"sshTermHelp": "Quando o terminal é rolável, arrastar horizontalmente pode selecionar texto. Clicar no botão do teclado ativa/desativa o teclado. O ícone de arquivo abre o SFTP do caminho atual. O botão da área de transferência copia o conteúdo quando o texto é selecionado e cola o conteúdo da área de transferência no terminal quando nenhum texto é selecionado e há conteúdo na área de transferência. O ícone de código cola trechos de código no terminal e os executa.",
"sshTip": "Esta funcionalidade está em fase de teste.\n\nPor favor, reporte problemas em {url} ou junte-se a nós no desenvolvimento.",
"sshVirtualKeyAutoOff": "Desativação automática das teclas virtuais",

View File

@@ -11,15 +11,15 @@
"autoConnect": "Автоматическое подключение",
"autoRun": "Автозапуск",
"autoUpdateHomeWidget": "Автоматическое обновление виджета на главном экране",
"backupTip": "Экспортированные данные могут быть зашифрованы паролем. \nПожалуйста, храните их в безопасности.",
"backupVersionNotMatch": "Версия резервной копии не совпадает, восстановление невозможно",
"backupPassword": "Пароль резервной копии",
"backupPasswordTip": "Установите пароль для шифрования файлов резервных копий. Оставьте пустым, чтобы отключить шифрование.",
"backupPasswordWrong": "Неверный пароль резервной копии",
"backupEncrypted": "Резервная копия зашифрована",
"backupNotEncrypted": "Резервная копия не зашифрована",
"backupPasswordSet": "Пароль резервной копии установлен",
"backupPassword": "Пароль резервной копии",
"backupPasswordRemoved": "Пароль резервной копии удален",
"backupPasswordSet": "Пароль резервной копии установлен",
"backupPasswordTip": "Установите пароль для шифрования файлов резервных копий. Оставьте пустым, чтобы отключить шифрование.",
"backupPasswordWrong": "Неверный пароль резервной копии",
"backupTip": "Экспортированные данные могут быть зашифрованы паролем. \nПожалуйста, храните их в безопасности.",
"backupVersionNotMatch": "Версия резервной копии не совпадает, восстановление невозможно",
"battery": "Батарея",
"bgRun": "Работа в фоновом режиме",
"bgRunTip": "Этот переключатель означает, что программа будет пытаться работать в фоновом режиме, но фактическое выполнение зависит от того, включено ли разрешение. Для нативного Android отключите «Оптимизацию батареи» для этого приложения, для MIUI измените контроль активности на «Нет ограничений».",
@@ -180,6 +180,19 @@
"specifyDevTip": "Например, статистика сетевого трафика по умолчанию относится ко всем устройствам. Здесь вы можете указать конкретное устройство.",
"speed": "Скорость",
"spentTime": "Затрачено времени: {time}",
"sshConfigAllExist": "Все серверы уже существуют (найдено {duplicateCount} дубликатов)",
"sshConfigDuplicatesSkipped": "{duplicateCount} дубликатов будут пропущены",
"sshConfigFound": "Мы нашли SSH-конфигурацию в вашей системе",
"sshConfigFoundServers": "Найдено {totalCount} серверов",
"sshConfigImport": "Импорт SSH Конфигурации",
"sshConfigImportHelp": "Можно импортировать только базовую информацию, например: IP/порт.",
"sshConfigImportPermission": "Хотите ли вы дать разрешение на чтение ~/.ssh/config и автоматический импорт настроек сервера?",
"sshConfigImportTip": "Предложение прочитать ~/.ssh/config при создании первого сервера",
"sshConfigImported": "Импортировано {count} серверов из SSH-конфигурации",
"sshConfigManualSelect": "Хотели бы вы вручную выбрать файл конфигурации SSH?",
"sshConfigNoServers": "Серверы не найдены в SSH-конфигурации",
"sshConfigPermissionDenied": "Невозможно получить доступ к файлу конфигурации SSH из-за разрешений macOS.",
"sshConfigServersToImport": "{importCount} серверов будут импортированы",
"sshTermHelp": "Когда терминал можно прокручивать, горизонтальное перетаскивание позволяет выделить текст. Нажатие на кнопку клавиатуры включает/выключает клавиатуру. Иконка файла открывает текущий путь SFTP. Кнопка буфера обмена копирует содержимое, когда текст выделен, и вставляет содержимое из буфера обмена в терминал, когда текст не выделен, а в буфере есть содержимое. Иконка кода вставляет фрагменты кода в терминал и выполняет их.",
"sshTip": "Эта функция находится в стадии тестирования.\n\nПожалуйста, отправляйте отчеты о проблемах на {url} или присоединяйтесь к нашей разработке.",
"sshVirtualKeyAutoOff": "Автоматическое переключение виртуальных клавиш",

View File

@@ -11,15 +11,15 @@
"autoConnect": "Otomatik bağlan",
"autoRun": "Otomatik çalıştır",
"autoUpdateHomeWidget": "Ana ekran bileşenini otomatik güncelle",
"backupTip": "Dışa aktarılan veriler parola ile şifrelenebilir. \nLütfen güvenli bir şekilde saklayın.",
"backupVersionNotMatch": "Yedekleme sürümü eşleşmiyor.",
"backupPassword": "Yedekleme parolası",
"backupPasswordTip": "Yedekleme dosyalarını şifrelemek için bir parola belirleyin. Şifrelemeyi devre dışı bırakmak için boş bırakın.",
"backupPasswordWrong": "Yanlış yedekleme parolası",
"backupEncrypted": "Yedekleme şifrelenmiş",
"backupNotEncrypted": "Yedekleme şifreli değil",
"backupPasswordSet": "Yedekleme parolası ayarlandı",
"backupPassword": "Yedekleme parolası",
"backupPasswordRemoved": "Yedekleme parolası kaldırıldı",
"backupPasswordSet": "Yedekleme parolası ayarlandı",
"backupPasswordTip": "Yedekleme dosyalarını şifrelemek için bir parola belirleyin. Şifrelemeyi devre dışı bırakmak için boş bırakın.",
"backupPasswordWrong": "Yanlış yedekleme parolası",
"backupTip": "Dışa aktarılan veriler parola ile şifrelenebilir. \nLütfen güvenli bir şekilde saklayın.",
"backupVersionNotMatch": "Yedekleme sürümü eşleşmiyor.",
"battery": "Pil",
"bgRun": "Arka planda çalıştır",
"bgRunTip": "Bu anahtar yalnızca programın arka planda çalışmayı deneyeceği anlamına gelir. Arka planda çalışıp çalışamayacağı, iznin etkinleştirilip etkinleştirilmediğine bağlıdır. AOSP tabanlı Android ROM'lar için lütfen bu uygulamada \"Pil Optimizasyonu\"nu devre dışı bırakın. MIUI / HyperOS için lütfen güç tasarrufu politikasını \"Sınırsız\" olarak değiştirin.",
@@ -180,6 +180,19 @@
"specifyDevTip": "Örneğin, ağ trafiği istatistikleri varsayılan olarak tüm cihazlar içindir. Burada belirli bir cihaz belirtebilirsiniz.",
"speed": "Hız",
"spentTime": "Harcanan süre: {time}",
"sshConfigAllExist": "Tüm sunucular zaten mevcut ({duplicateCount} kopya bulundu)",
"sshConfigDuplicatesSkipped": "{duplicateCount} kopya atlanacak",
"sshConfigFound": "Sisteminizde SSH yapılandırması bulduk",
"sshConfigFoundServers": "{totalCount} sunucu bulundu",
"sshConfigImport": "SSH Yapılandırma İçe Aktarma",
"sshConfigImportHelp": "Yalnızca temel bilgiler içe aktarılabilir, örneğin: IP/Port.",
"sshConfigImportPermission": "~/.ssh/config dosyasını okumak ve sunucu ayarlarını otomatik olarak içe aktarmak için izin vermek ister misiniz?",
"sshConfigImportTip": "İlk sunucu oluşturulurken ~/.ssh/config okuma istemi",
"sshConfigImported": "SSH yapılandırmasından {count} sunucu içe aktarıldı",
"sshConfigManualSelect": "SSH yapılandırma dosyasını manuel olarak seçmek ister misiniz?",
"sshConfigNoServers": "SSH yapılandırmasında sunucu bulunamadı",
"sshConfigPermissionDenied": "macOS izinleri nedeniyle SSH yapılandırma dosyasına erişilemiyor.",
"sshConfigServersToImport": "{importCount} sunucu içe aktarılacak",
"sshTermHelp": "Terminal kaydırılabilir olduğunda, yatay olarak sürüklemek metni seçebilir. Klavye düğmesine tıklamak klavyeyi açar/kapar. Dosya simgesi mevcut yolu SFTP'de açar. Pano düğmesi, metin seçiliyken içeriği kopyalar ve metin seçili değilken panoda içerik varsa terminale yapıştırır. Kod simgesi, kod parçacıklarını terminale yapıştırır ve yürütür.",
"sshTip": "Bu işlev şu anda deneysel aşamada.\n\nLütfen hataları {url} adresinde bildirin veya geliştirmemize katılın.",
"sshVirtualKeyAutoOff": "Sanal tuşların otomatik geçişi",

View File

@@ -11,15 +11,15 @@
"autoConnect": "Авто підключення",
"autoRun": "Авто запуск",
"autoUpdateHomeWidget": "Автоматичне оновлення віджетів на головному екрані",
"backupTip": "Експортовані дані можуть бути зашифровані паролем. \nБудь ласка, зберігайте їх у безпеці.",
"backupVersionNotMatch": "Версія резервного копіювання не збіглася.",
"backupPassword": "Пароль резервного копіювання",
"backupPasswordTip": "Встановіть пароль для шифрування файлів резервного копіювання. Залиште порожнім для відключення шифрування.",
"backupPasswordWrong": "Неправильний пароль резервного копіювання",
"backupEncrypted": "Резервна копія зашифрована",
"backupNotEncrypted": "Резервна копія не зашифрована",
"backupPasswordSet": "Пароль резервного копіювання встановлено",
"backupPassword": "Пароль резервного копіювання",
"backupPasswordRemoved": "Пароль резервного копіювання видалено",
"backupPasswordSet": "Пароль резервного копіювання встановлено",
"backupPasswordTip": "Встановіть пароль для шифрування файлів резервного копіювання. Залиште порожнім для відключення шифрування.",
"backupPasswordWrong": "Неправильний пароль резервного копіювання",
"backupTip": "Експортовані дані можуть бути зашифровані паролем. \nБудь ласка, зберігайте їх у безпеці.",
"backupVersionNotMatch": "Версія резервного копіювання не збіглася.",
"battery": "Акумулятор",
"bgRun": "Запуск у фоновому режимі",
"bgRunTip": "Цей перемикач лише вказує на те, що програма намагатиметься працювати у фоновому режимі. Чи може вона працювати у фоновому режимі, залежить від прав доступу. Для AOSP-орієнтованих Android ROM, будь ласка, вимкніть \"Оптимізацію акумулятора\" в цьому додатку. Для MIUI / HyperOS, будь ласка, змініть політику економії енергії на \"Нескінченна\".",
@@ -180,6 +180,19 @@
"specifyDevTip": "Наприклад, статистика мережевого трафіку за замовчуванням є для всіх пристроїв. Ви можете вказати певний пристрій тут.",
"speed": "Швидкість",
"spentTime": "Витрачений час: {time}",
"sshConfigAllExist": "Всі сервери вже існують (знайдено {duplicateCount} дублікатів)",
"sshConfigDuplicatesSkipped": "{duplicateCount} дублікатів буде пропущено",
"sshConfigFound": "Ми знайшли SSH-конфігурацію у вашій системі",
"sshConfigFoundServers": "Знайдено {totalCount} серверів",
"sshConfigImport": "Імпорт SSH Конфігурації",
"sshConfigImportHelp": "Можна імпортувати лише базову інформацію, наприклад: IP/порт.",
"sshConfigImportPermission": "Чи хочете ви надати дозвіл на читання ~/.ssh/config та автоматичний імпорт налаштувань сервера?",
"sshConfigImportTip": "Пропозиція прочитати ~/.ssh/config при створенні першого сервера",
"sshConfigImported": "Імпортовано {count} серверів з SSH-конфігурації",
"sshConfigManualSelect": "Чи хочете ви вручну вибрати файл конфігурації SSH?",
"sshConfigNoServers": "Сервери не знайдені в SSH-конфігурації",
"sshConfigPermissionDenied": "Неможливо отримати доступ до файлу конфігурації SSH через дозволи macOS.",
"sshConfigServersToImport": "{importCount} серверів буде імпортовано",
"sshTermHelp": "Коли термінал прокрутний, горизонтальне проведення вибирає текст. Натискання кнопки клавіатури вмикає/вимикає клавіатуру. Іконка файлу відкриває поточний шлях SFTP. Кнопка буфера обміну копіює вміст, коли текст вибрано, і вставляє вміст з буфера обміну в термінал, коли текст не вибрано і є вміст у буфері обміну. Іконка коду вставляє фрагменти коду в термінал і виконує їх.",
"sshTip": "Ця функція наразі в експериментальній стадії. Будь ласка, повідомте про помилки за адресою {url} або приєднуйтеся до нашої розробки.",
"sshVirtualKeyAutoOff": "Автоматичне переключення віртуальних клавіш",

View File

@@ -11,15 +11,15 @@
"autoConnect": "自动连接",
"autoRun": "自动运行",
"autoUpdateHomeWidget": "自动更新桌面小部件",
"backupTip": "导出数据可通过密码加密,请妥善保管。",
"backupVersionNotMatch": "备份版本不兼容,无法恢复",
"backupPassword": "备份密码",
"backupPasswordTip": "设置密码以加密备份文件。留空则禁用加密。",
"backupPasswordWrong": "备份密码错误",
"backupEncrypted": "备份已加密",
"backupNotEncrypted": "备份未加密",
"backupPasswordSet": "备份密码已设置",
"backupPassword": "备份密码",
"backupPasswordRemoved": "备份密码已移除",
"backupPasswordSet": "备份密码已设置",
"backupPasswordTip": "设置密码以加密备份文件。留空则禁用加密。",
"backupPasswordWrong": "备份密码错误",
"backupTip": "导出数据可通过密码加密,请妥善保管。",
"backupVersionNotMatch": "备份版本不兼容,无法恢复",
"battery": "电池",
"bgRun": "后台运行",
"bgRunTip": "此开关只代表程序会尝试在后台运行,具体能否后台运行取决于是否开启了权限。原生 Android 请关闭本 App 的“电池优化”MIUI / HyperOS 请将省电策略改为“无限制”。",
@@ -180,6 +180,19 @@
"specifyDevTip": "例如网络流量统计默认是所有设备,你可以在这里指定特定的设备",
"speed": "速度",
"spentTime": "耗时:{time}",
"sshConfigAllExist": "所有服务器已存在(发现 {duplicateCount} 个重复项)",
"sshConfigDuplicatesSkipped": "{duplicateCount} 个重复项将被跳过",
"sshConfigFound": "我们在您的系统中发现了 SSH 配置。",
"sshConfigFoundServers": "发现 {totalCount} 个服务器",
"sshConfigImport": "SSH 配置导入",
"sshConfigImportHelp": "只能导入基础信息例如IP/端口",
"sshConfigImportPermission": "是否允许读取 ~/.ssh/config 并自动导入服务器设置?",
"sshConfigImportTip": "首次创建服务器时提示读取 ~/.ssh/config",
"sshConfigImported": "从 SSH 配置导入了 {count} 个服务器",
"sshConfigManualSelect": "是否要手动选择 SSH 配置文件?",
"sshConfigNoServers": "SSH 配置中未找到服务器",
"sshConfigPermissionDenied": "由于 macOS 权限限制,无法访问 SSH 配置文件。",
"sshConfigServersToImport": "{importCount} 个服务器将被导入",
"sshTermHelp": "在终端可滚动时,横向拖动可以选中文字。点击键盘按钮可以开启/关闭键盘。文件图标会打开当前路径 SFTP。剪切板按钮会在有选中文字时复制内容在未选中并且剪切板有内容时粘贴内容到终端。代码图标会粘贴代码片段到终端并执行。",
"sshTip": "该功能目前处于测试阶段。\n\n请在 {url} 反馈问题,或者加入我们开发。",
"sshVirtualKeyAutoOff": "虚拟按键自动切换",

View File

@@ -11,15 +11,15 @@
"autoConnect": "自動連線",
"autoRun": "自動執行",
"autoUpdateHomeWidget": "自動更新桌面小工具",
"backupTip": "匯出的資料可透過密碼加密,請妥善保管。",
"backupVersionNotMatch": "備份版本不相容,無法還原",
"backupPassword": "備份密碼",
"backupPasswordTip": "設定密碼來加密備份檔案。留空則停用加密。",
"backupPasswordWrong": "備份密碼錯誤",
"backupEncrypted": "備份已加密",
"backupNotEncrypted": "備份未加密",
"backupPasswordSet": "備份密碼已設定",
"backupPassword": "備份密碼",
"backupPasswordRemoved": "備份密碼已移除",
"backupPasswordSet": "備份密碼已設定",
"backupPasswordTip": "設定密碼來加密備份檔案。留空則停用加密。",
"backupPasswordWrong": "備份密碼錯誤",
"backupTip": "匯出的資料可透過密碼加密,請妥善保管。",
"backupVersionNotMatch": "備份版本不相容,無法還原",
"battery": "電池",
"bgRun": "背景執行",
"bgRunTip": "此開關僅代表程式會嘗試於背景執行,能否成功取決於系統權限。在原生 Android 上,請關閉本應用的「電池最佳化」;在 MIUI / HyperOS 上,請將省電策略調整為「無限制」。",
@@ -180,6 +180,19 @@
"specifyDevTip": "例如網路流量統計預設是所有裝置,你可以在這裡指定特定的裝置。",
"speed": "速度",
"spentTime": "耗時:{time}",
"sshConfigAllExist": "所有伺服器均已存在(發現{duplicateCount}個重複項)",
"sshConfigDuplicatesSkipped": "將跳過{duplicateCount}個重複項",
"sshConfigFound": "我們在您的系統中發現了SSH設定",
"sshConfigFoundServers": "發現{totalCount}個伺服器",
"sshConfigImport": "匯入SSH設定",
"sshConfigImportHelp": "只能匯入基礎資訊例如IP/端口。",
"sshConfigImportPermission": "您是否希望允許讀取 ~/.ssh/config 並自動匯入伺服器設定?",
"sshConfigImportTip": "在建立第一個伺服器時提示讀取 ~/.ssh/config",
"sshConfigImported": "已從SSH設定匯入{count}個伺服器",
"sshConfigManualSelect": "是否要手動選擇 SSH 設定檔案?",
"sshConfigNoServers": "SSH設定中未找到伺服器",
"sshConfigPermissionDenied": "由於 macOS 權限限制,無法存取 SSH 設定檔案。",
"sshConfigServersToImport": "將匯入{importCount}個伺服器",
"sshTermHelp": "在終端機可捲動時,橫向拖動可以選中文字。點擊鍵盤按鈕可以開啟/關閉鍵盤。檔案圖示會打開目前路徑 SFTP。剪貼簿按鈕會在有選中文字時複製內容在未選中並且剪貼簿有內容時貼上內容到終端機。程式碼圖示會貼上程式碼片段到終端機並執行。",
"sshTip": "該功能目前處於測試階段。\n\n請在 {url} 回饋問題,或者加入我們開發。",
"sshVirtualKeyAutoOff": "虛擬按鍵自動切換",

View File

@@ -6,16 +6,12 @@ import 'package:computer/computer.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:logging/logging.dart';
import 'package:server_box/app.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/server_detail_card.dart';
import 'package:server_box/data/provider/private_key.dart';
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/store.dart';
import 'package:server_box/data/ssh/session_manager.dart';
@@ -25,7 +21,7 @@ import 'package:server_box/hive/hive_registrar.g.dart';
Future<void> main() async {
_runInZone(() async {
await _initApp();
runApp(const MyApp());
runApp(ProviderScope(child: const MyApp()));
});
}
@@ -65,12 +61,6 @@ Future<void> _initData() async {
// DO DB migration before load any provider.
await _doDbMigrate();
// DO NOT change the order of these providers.
PrivateKeyProvider.instance.load();
SnippetProvider.instance.load();
ServerProvider.instance.load();
SftpProvider.instance.load();
if (Stores.setting.betaTest.fetch()) AppUpdate.chan = AppUpdateChan.beta;
FontUtils.loadFrom(Stores.setting.fontPath.fetch());
@@ -94,8 +84,6 @@ void _doPlatformRelated() async {
final serversCount = Stores.server.keys().length;
Computer.shared.turnOn(workersCount: (serversCount / 3).round() + 1); // Plus 1 to avoid 0.
bakSync.sync();
}
// It may contains some async heavy funcs.

View File

@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:computer/computer.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/sync.dart';
@@ -17,16 +18,16 @@ import 'package:server_box/data/res/misc.dart';
import 'package:server_box/data/res/store.dart';
import 'package:webdav_client_plus/webdav_client_plus.dart';
class BackupPage extends StatefulWidget {
class BackupPage extends ConsumerStatefulWidget {
const BackupPage({super.key});
@override
State<BackupPage> createState() => _BackupPageState();
ConsumerState<BackupPage> createState() => _BackupPageState();
static const route = AppRouteNoArg(page: BackupPage.new, path: '/backup');
}
final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveClientMixin {
final class _BackupPageState extends ConsumerState<BackupPage> with AutomaticKeepAliveClientMixin {
final webdavLoading = false.vn;
final gistLoading = false.vn;
@@ -130,14 +131,6 @@ final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveCl
}
}
Future<bool> _ensureBakPwd(BuildContext context) async {
final saved = await SecureStoreProps.bakPwd.read();
if (saved != null && saved.isNotEmpty) return true;
await _onTapSetBakPwd(context);
final after = await SecureStoreProps.bakPwd.read();
return after != null && after.isNotEmpty;
}
Widget get _buildTip {
return CardX(
child: ListTile(
@@ -367,7 +360,13 @@ final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveCl
onTap: () async {
final data = await context.showImportDialog(title: l10n.snippet, modelDef: Snippet.example.toJson());
if (data == null) return;
final str = String.fromCharCodes(data);
String str;
try {
str = utf8.decode(data);
} on FormatException catch (e, s) {
context.showErrDialog(e, s, libL10n.error);
return;
}
final (list, _) = await context.showLoadingDialog(
fn: () => Computer.shared.start((s) {
return json.decode(s) as List;
@@ -401,8 +400,9 @@ final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveCl
child: SingleChildScrollView(child: Text(libL10n.askContinue('${libL10n.import} [$snippetNames]'))),
actions: Btn.ok(
onTap: () {
final notifier = ref.read(snippetNotifierProvider.notifier);
for (final snippet in snippets) {
SnippetProvider.add(snippet);
notifier.add(snippet);
}
context.pop();
context.pop();
@@ -413,6 +413,11 @@ final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveCl
).cardx;
}
@override
bool get wantKeepAlive => true;
}
extension on _BackupPageState {
Future<void> _onTapWebdavDl(BuildContext context) async {
webdavLoading.value = true;
try {
@@ -441,7 +446,7 @@ final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveCl
final ok = await _ensureBakPwd(context);
if (!ok) return;
final savedPassword = await SecureStoreProps.bakPwd.read();
await BackupV2.backup(bakName, savedPassword);
await BackupV2.backup(bakName, savedPassword?.isEmpty == true ? null : savedPassword);
await Webdav.shared.upload(relativePath: bakName);
Loggers.app.info('Upload webdav backup success');
} catch (e, s) {
@@ -480,7 +485,7 @@ final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveCl
final ok = await _ensureBakPwd(context);
if (!ok) return;
final savedPassword = await SecureStoreProps.bakPwd.read();
await BackupV2.backup(bakName, savedPassword);
await BackupV2.backup(bakName, savedPassword?.isEmpty == true ? null : savedPassword);
await GistRs.shared.upload(relativePath: bakName);
Loggers.app.info('Upload gist backup success');
} catch (e, s) {
@@ -589,7 +594,13 @@ final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveCl
void _onBulkImportServers(BuildContext context) async {
final data = await context.showImportDialog(title: l10n.server, modelDef: Spix.example.toJson());
if (data == null) return;
final text = String.fromCharCodes(data);
String text;
try {
text = utf8.decode(data);
} on FormatException catch (e, s) {
context.showErrDialog(e, s, libL10n.error);
return;
}
try {
final (spis, err) = await context.showLoadingDialog(
@@ -607,8 +618,15 @@ final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveCl
if (sure == true) {
final (suc, err) = await context.showLoadingDialog(
fn: () async {
final usedIds = <String>{};
for (var spi in spis) {
Stores.server.put(spi);
// Ensure each server has a unique ID
// Only generate a new ID if the imported one is empty or already used in importing stage
final isIdUsed = spi.id.isNotEmpty || usedIds.contains(spi.id);
final spiWithId = isIdUsed ? spi.copyWith(id: ShortId.generate()) : spi;
Stores.server.put(spiWithId);
usedIds.add(spiWithId.id);
}
return true;
},
@@ -622,6 +640,29 @@ final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveCl
}
}
@override
bool get wantKeepAlive => true;
Future<bool> _ensureBakPwd(BuildContext context) async {
final saved = await SecureStoreProps.bakPwd.read();
if (saved != null && saved.isNotEmpty) return true;
// Show dialog asking if user wants to set password or continue without
final result = await context.showRoundDialog<bool>(
title: l10n.backupPassword,
child: Text(l10n.backupPasswordTip, style: UIs.textGrey),
actions: [
TextButton(onPressed: () => context.pop(true), child: Text(libL10n.cancel)),
TextButton(onPressed: () => context.pop(false), child: Text(libL10n.setting)),
],
);
if (result == true) {
// Continue without password
return true;
} else if (result == false) {
// User wants to set password
await _onTapSetBakPwd(context);
return true; // Allow continuing even if password setting was cancelled
}
return false; // User cancelled the dialog
}
}

View File

@@ -1,6 +1,12 @@
part of 'container.dart';
extension on _ContainerPageState {
/// The notifier for the container state.
ContainerNotifier get _containerNotifier => ref.read(_provider.notifier);
/// Watch the current state of the container.
ContainerState get _containerState => ref.watch(_provider);
Future<void> _showAddFAB() async {
final imageCtrl = TextEditingController();
final nameCtrl = TextEditingController();
@@ -79,7 +85,7 @@ extension on _ContainerPageState {
onPressed: () async {
context.pop();
final (result, err) = await context.showLoadingDialog(fn: () => _container.run(cmd));
final (result, err) = await context.showLoadingDialog(fn: () => _containerNotifier.run(cmd));
if (err != null || result != null) {
final e = result?.message ?? err?.toString();
context.showRoundDialog(title: libL10n.error, child: Text(e.toString()));
@@ -111,7 +117,7 @@ extension on _ContainerPageState {
void _onSaveDockerHost(String val) {
context.pop();
Stores.container.put(widget.args.spi.id, val.trim());
_container.refresh();
_containerNotifier.refresh();
}
void _showImageRmDialog(ContainerImg e) {
@@ -121,7 +127,7 @@ extension on _ContainerPageState {
actions: Btn.ok(
onTap: () async {
context.pop();
final result = await _container.run('rmi ${e.id} -f');
final result = await _containerNotifier.run('rmi ${e.id} -f');
if (result != null) {
context.showSnackBar(result.message ?? 'null');
}
@@ -163,7 +169,9 @@ extension on _ContainerPageState {
onTap: () async {
context.pop();
final (result, err) = await context.showLoadingDialog(fn: () => _container.delete(id, force));
final (result, err) = await context.showLoadingDialog(
fn: () => _containerNotifier.delete(id, force),
);
if (err != null || result != null) {
final e = result?.message ?? err?.toString();
context.showRoundDialog(title: libL10n.error, child: Text(e ?? 'null'));
@@ -173,21 +181,21 @@ extension on _ContainerPageState {
);
break;
case ContainerMenu.start:
final (result, err) = await context.showLoadingDialog(fn: () => _container.start(id));
final (result, err) = await context.showLoadingDialog(fn: () => _containerNotifier.start(id));
if (err != null || result != null) {
final e = result?.message ?? err?.toString();
context.showRoundDialog(title: libL10n.error, child: Text(e ?? 'null'));
}
break;
case ContainerMenu.stop:
final (result, err) = await context.showLoadingDialog(fn: () => _container.stop(id));
final (result, err) = await context.showLoadingDialog(fn: () => _containerNotifier.stop(id));
if (err != null || result != null) {
final e = result?.message ?? err?.toString();
context.showRoundDialog(title: libL10n.error, child: Text(e ?? 'null'));
}
break;
case ContainerMenu.restart:
final (result, err) = await context.showLoadingDialog(fn: () => _container.restart(id));
final (result, err) = await context.showLoadingDialog(fn: () => _containerNotifier.restart(id));
if (err != null || result != null) {
final e = result?.message ?? err?.toString();
context.showRoundDialog(title: libL10n.error, child: Text(e ?? 'null'));
@@ -197,7 +205,7 @@ extension on _ContainerPageState {
final args = SshPageArgs(
spi: widget.args.spi,
initCmd:
'${switch (_container.type) {
'${switch (_containerState.type) {
ContainerType.podman => 'podman',
ContainerType.docker => 'docker',
}} logs -f --tail 100 ${dItem.id}',
@@ -208,7 +216,7 @@ extension on _ContainerPageState {
final args = SshPageArgs(
spi: widget.args.spi,
initCmd:
'${switch (_container.type) {
'${switch (_containerState.type) {
ContainerType.podman => 'podman',
ContainerType.docker => 'docker',
}} exec -it ${dItem.id} sh',
@@ -222,7 +230,7 @@ extension on _ContainerPageState {
if (Stores.setting.containerAutoRefresh.fetch()) {
Timer.periodic(Duration(seconds: Stores.setting.serverStatusUpdateInterval.fetch()), (timer) {
if (mounted) {
_container.refresh(isAuto: true);
_containerNotifier.refresh(isAuto: true);
} else {
timer.cancel();
}

View File

@@ -2,8 +2,8 @@ import 'dart:async';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:provider/provider.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/route.dart';
import 'package:server_box/data/model/app/error.dart';
@@ -12,81 +12,79 @@ import 'package:server_box/data/model/app/menu/container.dart';
import 'package:server_box/data/model/container/image.dart';
import 'package:server_box/data/model/container/ps.dart';
import 'package:server_box/data/model/container/type.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/provider/container.dart';
import 'package:server_box/data/provider/server/single.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/view/page/ssh/page/page.dart';
part 'actions.dart';
part 'types.dart';
class ContainerPage extends StatefulWidget {
class ContainerPage extends ConsumerStatefulWidget {
final SpiRequiredArgs args;
const ContainerPage({required this.args, super.key});
@override
State<ContainerPage> createState() => _ContainerPageState();
ConsumerState<ContainerPage> createState() => _ContainerPageState();
static const route = AppRouteArg(page: ContainerPage.new, path: '/container');
}
class _ContainerPageState extends State<ContainerPage> {
class _ContainerPageState extends ConsumerState<ContainerPage> {
final _textController = TextEditingController();
late final _container = ContainerProvider(
client: widget.args.spi.server?.value.client,
userName: widget.args.spi.user,
hostId: widget.args.spi.id,
context: context,
);
late final ContainerNotifierProvider _provider;
@override
void dispose() {
super.dispose();
_textController.dispose();
_container.dispose();
}
@override
void initState() {
super.initState();
final serverState = ref.read(serverNotifierProvider(widget.args.spi.id));
_provider = containerNotifierProvider(
serverState.client,
widget.args.spi.user,
widget.args.spi.id,
context,
);
_initAutoRefresh();
}
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => _container,
builder: (_, _) => Consumer<ContainerProvider>(
builder: (_, _, _) {
return Scaffold(
appBar: _buildAppBar,
body: SafeArea(child: _buildMain),
floatingActionButton: _container.error == null ? _buildFAB : null,
);
},
),
final err = ref.watch(_provider.select((p) => p.error));
return Scaffold(
appBar: _buildAppBar(),
body: SafeArea(child: _buildMain()),
floatingActionButton: err == null ? _buildFAB() : null,
);
}
CustomAppBar get _buildAppBar {
CustomAppBar _buildAppBar() {
return CustomAppBar(
centerTitle: true,
title: TwoLineText(up: l10n.container, down: widget.args.spi.name),
actions: [
IconButton(
onPressed: () => context.showLoadingDialog(fn: () => _container.refresh()),
onPressed: () => context.showLoadingDialog(fn: () => _containerNotifier.refresh()),
icon: const Icon(Icons.refresh),
),
],
);
}
Widget get _buildFAB {
Widget _buildFAB() {
return FloatingActionButton(onPressed: () async => await _showAddFAB(), child: const Icon(Icons.add));
}
Widget get _buildMain {
if (_container.error != null && _container.items == null) {
Widget _buildMain() {
final containerState = _containerState;
if (containerState.error != null && containerState.items == null) {
return SizedBox.expand(
child: Column(
children: [
@@ -95,7 +93,7 @@ class _ContainerPageState extends State<ContainerPage> {
UIs.height13,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 23),
child: Text(_container.error.toString()),
child: Text(containerState.error.toString()),
),
const Spacer(),
UIs.height13,
@@ -104,27 +102,27 @@ class _ContainerPageState extends State<ContainerPage> {
).paddingSymmetric(horizontal: 13),
);
}
if (_container.items == null || _container.images == null) {
if (containerState.items == null || containerState.images == null) {
return UIs.centerLoading;
}
return AutoMultiList(
children: <Widget>[
_buildLoading(),
_buildVersion(),
_buildPs(),
_buildImage(),
_buildEmptyStateMessage(),
_buildLoading(containerState),
_buildVersion(containerState),
_buildPs(containerState),
_buildImage(containerState),
_buildEmptyStateMessage(containerState),
_buildPruneBtns,
_buildSettingsBtns,
],
);
}
Widget _buildEmptyStateMessage() {
final emptyImgs = _container.images?.isEmpty ?? true;
final emptyPs = _container.items?.isEmpty ?? true;
if (emptyPs && emptyImgs && _container.runLog == null) {
Widget _buildEmptyStateMessage(ContainerState containerState) {
final emptyImgs = containerState.images?.isEmpty ?? true;
final emptyPs = containerState.items?.isEmpty ?? true;
if (emptyPs && emptyImgs && containerState.runLog == null) {
return CardX(
child: Padding(
padding: const EdgeInsets.fromLTRB(17, 17, 17, 7),
@@ -135,13 +133,13 @@ class _ContainerPageState extends State<ContainerPage> {
return UIs.placeholder;
}
Widget _buildImage() {
Widget _buildImage(ContainerState containerState) {
return ExpandTile(
leading: const Icon(MingCute.clapperboard_line),
title: Text(l10n.imagesList),
subtitle: Text(l10n.dockerImagesFmt(_container.images!.length), style: UIs.textGrey),
initiallyExpanded: (_container.images?.length ?? 0) <= 3,
children: _container.images?.map(_buildImageItem).toList() ?? [],
subtitle: Text(l10n.dockerImagesFmt(containerState.images?.length ?? 'null'), style: UIs.textGrey),
initiallyExpanded: (containerState.images?.length ?? 0) <= 3,
children: containerState.images?.map(_buildImageItem).toList() ?? [],
).cardx;
}
@@ -161,34 +159,34 @@ class _ContainerPageState extends State<ContainerPage> {
);
}
Widget _buildLoading() {
if (_container.runLog == null) return UIs.placeholder;
Widget _buildLoading(ContainerState containerState) {
if (containerState.runLog == null) return UIs.placeholder;
return Padding(
padding: const EdgeInsets.all(17),
child: Column(
children: [
const Center(child: CircularProgressIndicator()),
UIs.height13,
Text(_container.runLog ?? '...'),
Text(containerState.runLog ?? '...'),
],
),
);
}
Widget _buildVersion() {
Widget _buildVersion(ContainerState containerState) {
return CardX(
child: Padding(
padding: const EdgeInsets.all(17),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Text(_container.type.name.capitalize), Text(_container.version ?? l10n.unknown)],
children: [Text(containerState.type.name.capitalize), Text(containerState.version ?? l10n.unknown)],
),
),
);
}
Widget _buildPs() {
final items = _container.items;
Widget _buildPs(ContainerState containerState) {
final items = containerState.items;
if (items == null) return UIs.placeholder;
final running = items.where((e) => e.running).length;
final stopped = items.length - running;
@@ -309,16 +307,17 @@ class _ContainerPageState extends State<ContainerPage> {
Widget _buildPruneBtn(_PruneTypes type) {
final title = type.name.capitalize;
final containerNotifier = _containerNotifier;
return ListTile(
onTap: () async {
await _showPruneDialog(
title: title,
message: type.tip,
onConfirm: switch (type) {
_PruneTypes.images => _container.pruneImages,
_PruneTypes.containers => _container.pruneContainers,
_PruneTypes.volumes => _container.pruneVolumes,
_PruneTypes.system => _container.pruneSystem,
_PruneTypes.images => containerNotifier.pruneImages,
_PruneTypes.containers => containerNotifier.pruneContainers,
_PruneTypes.volumes => containerNotifier.pruneVolumes,
_PruneTypes.system => containerNotifier.pruneSystem,
},
);
},
@@ -330,22 +329,26 @@ class _ContainerPageState extends State<ContainerPage> {
Widget get _buildSettingsBtns {
final len = _SettingsMenuItems.values.length;
if (len == 0) return UIs.placeholder;
final containerState = _containerState;
return ExpandTile(
leading: const Icon(Icons.settings),
title: Text(libL10n.setting),
initiallyExpanded: _container.error != null,
children: _SettingsMenuItems.values.map(_buildSettingTile).toList(),
initiallyExpanded: containerState.error != null,
children: _SettingsMenuItems.values.map((item) => _buildSettingTile(item, containerState)).toList(),
).cardx;
}
Widget _buildSettingTile(_SettingsMenuItems item) {
Widget _buildSettingTile(_SettingsMenuItems item, ContainerState containerState) {
final String title;
switch (item) {
case _SettingsMenuItems.editDockerHost:
title = '${libL10n.edit} DOCKER_HOST';
break;
case _SettingsMenuItems.switchProvider:
title = _container.type == ContainerType.podman ? l10n.switchTo('Docker') : l10n.switchTo('Podman');
title = containerState.type == ContainerType.podman
? l10n.switchTo('Docker')
: l10n.switchTo('Podman');
break;
}
return ListTile(
@@ -355,9 +358,11 @@ class _ContainerPageState extends State<ContainerPage> {
_showEditHostDialog();
break;
case _SettingsMenuItems.switchProvider:
_container.setType(
_container.type == ContainerType.docker ? ContainerType.podman : ContainerType.docker,
);
ref
.read(_provider.notifier)
.setType(
containerState.type == ContainerType.docker ? ContainerType.podman : ContainerType.docker,
);
break;
}
},

View File

@@ -1,26 +1,28 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:server_box/core/chan.dart';
import 'package:server_box/core/sync.dart';
import 'package:server_box/data/model/app/tab.dart';
import 'package:server_box/data/provider/server.dart';
import 'package:server_box/data/provider/server/all.dart';
import 'package:server_box/data/res/build_data.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/data/res/url.dart';
import 'package:server_box/view/page/setting/entry.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
class HomePage extends StatefulWidget {
class HomePage extends ConsumerStatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
ConsumerState<HomePage> createState() => _HomePageState();
static const route = AppRouteNoArg(page: HomePage.new, path: '/');
}
class _HomePageState extends State<HomePage>
with AutomaticKeepAliveClientMixin, AfterLayoutMixin, WidgetsBindingObserver {
class _HomePageState extends ConsumerState<HomePage>
with AutomaticKeepAliveClientMixin, AfterLayoutMixin, WidgetsBindingObserver, GlobalRef {
late final PageController _pageController;
final _selectIndex = ValueNotifier(0);
@@ -29,11 +31,14 @@ class _HomePageState extends State<HomePage>
bool _shouldAuth = false;
DateTime? _pausedTime;
late final _notifier = ref.read(serversNotifierProvider.notifier);
late final _provider = ref.read(serversNotifierProvider);
@override
void dispose() {
super.dispose();
WidgetsBinding.instance.removeObserver(this);
ServerProvider.closeServer();
Future(() => _notifier.closeServer());
_pageController.dispose();
WakelockPlus.disable();
@@ -76,8 +81,9 @@ class _HomePageState extends State<HomePage>
_goAuth();
}
}
if (!ServerProvider.isAutoRefreshOn) {
ServerProvider.startAutoRefresh();
final serverNotifier = _notifier;
if (_provider.autoRefreshTimer == null) {
serverNotifier.startAutoRefresh();
}
MethodChans.updateHomeWidget();
break;
@@ -92,7 +98,7 @@ class _HomePageState extends State<HomePage>
// }
} else {
//Pros.server.setDisconnected();
ServerProvider.stopAutoRefresh();
_notifier.stopAutoRefresh();
}
break;
default:
@@ -194,7 +200,9 @@ class _HomePageState extends State<HomePage>
AppUpdateIface.doUpdate(build: BuildData.build, url: Urls.updateCfg, context: context);
}
MethodChans.updateHomeWidget();
await ServerProvider.refresh();
await _notifier.refresh();
bakSync.sync(milliDelay: 1000);
}
// Future<void> _reqNotiPerm() async {
@@ -202,7 +210,6 @@ class _HomePageState extends State<HomePage>
// final suc = await PermUtils.request(Permission.notification);
// if (!suc) {
// final noNotiPerm = Stores.setting.noNotiPerm;
// if (noNotiPerm.fetch()) return;
// context.showRoundDialog(
// title: l10n.error,
// child: Text(l10n.noNotiPerm),
@@ -212,6 +219,7 @@ class _HomePageState extends State<HomePage>
// noNotiPerm.put(true);
// context.pop();
// },
// if (noNotiPerm.fetch()) return;
// child: Text(l10n.ok),
// ),
// ],

View File

@@ -2,23 +2,25 @@ import 'dart:async';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/model/server/ping_result.dart';
import 'package:server_box/data/provider/server.dart';
import 'package:server_box/data/provider/server/all.dart';
import 'package:server_box/data/provider/server/single.dart';
/// Only permit ipv4 / ipv6 / domain chars
final targetReg = RegExp(r'[a-zA-Z0-9\.-_:]+');
class PingPage extends StatefulWidget {
class PingPage extends ConsumerStatefulWidget {
const PingPage({super.key});
@override
State<PingPage> createState() => _PingPageState();
ConsumerState<PingPage> createState() => _PingPageState();
static const route = AppRouteNoArg(page: PingPage.new, path: '/ping');
}
class _PingPageState extends State<PingPage> with AutomaticKeepAliveClientMixin {
class _PingPageState extends ConsumerState<PingPage> with AutomaticKeepAliveClientMixin {
late TextEditingController _textEditingController;
final _results = ValueNotifier(<PingResult>[]);
bool get isInit => _results.value.isEmpty;
@@ -129,7 +131,7 @@ class _PingPageState extends State<PingPage> with AutomaticKeepAliveClientMixin
return;
}
if (ServerProvider.serverOrder.value.isEmpty) {
if (ref.read(serversNotifierProvider).serverOrder.isEmpty) {
context.showSnackBar(l10n.pingNoServer);
return;
}
@@ -141,13 +143,13 @@ class _PingPageState extends State<PingPage> with AutomaticKeepAliveClientMixin
}
await Future.wait(
ServerProvider.servers.values.map((v) async {
final e = v.value;
if (e.client == null) {
ref.read(serversNotifierProvider).servers.values.map((spi) async {
final serverState = ref.read(serverNotifierProvider(spi.id));
if (serverState.client == null) {
return;
}
final result = await e.client!.run('ping -c 3 $target').string;
_results.value.add(PingResult.parse(e.spi.name, result));
final result = await serverState.client!.run('ping -c 3 $target').string;
_results.value.add(PingResult.parse(spi.name, result));
// [ValueNotifier] only notify when value is changed
// But we just add a element to list without changing the list itself
// So we need to notify manually

View File

@@ -4,6 +4,7 @@ import 'package:computer/computer.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/utils/server.dart';
import 'package:server_box/data/model/server/private_key_info.dart';
@@ -17,17 +18,17 @@ final class PrivateKeyEditPageArgs {
const PrivateKeyEditPageArgs({this.pki});
}
class PrivateKeyEditPage extends StatefulWidget {
class PrivateKeyEditPage extends ConsumerStatefulWidget {
final PrivateKeyEditPageArgs? args;
const PrivateKeyEditPage({super.key, this.args});
@override
State<PrivateKeyEditPage> createState() => _PrivateKeyEditPageState();
ConsumerState<PrivateKeyEditPage> createState() => _PrivateKeyEditPageState();
static const route = AppRoute(page: PrivateKeyEditPage.new, path: '/private_key/edit');
}
class _PrivateKeyEditPageState extends State<PrivateKeyEditPage> {
class _PrivateKeyEditPageState extends ConsumerState<PrivateKeyEditPage> {
final _nameController = TextEditingController();
final _keyController = TextEditingController();
final _pwdController = TextEditingController();
@@ -39,6 +40,8 @@ class _PrivateKeyEditPageState extends State<PrivateKeyEditPage> {
final _loading = ValueNotifier<Widget?>(null);
late final _notifier = ref.read(privateKeyNotifierProvider.notifier);
PrivateKeyInfo? get pki => widget.args?.pki;
@override
@@ -94,7 +97,7 @@ class _PrivateKeyEditPageState extends State<PrivateKeyEditPage> {
child: Text(libL10n.askContinue('${libL10n.delete} ${l10n.privateKey}(${pki.id})')),
actions: Btn.ok(
onTap: () {
PrivateKeyProvider.delete(pki);
_notifier.delete(pki);
context.pop();
context.pop();
},
@@ -196,9 +199,9 @@ class _PrivateKeyEditPageState extends State<PrivateKeyEditPage> {
final pki = PrivateKeyInfo(id: name, key: decrypted);
final originPki = this.pki;
if (originPki != null) {
PrivateKeyProvider.update(originPki, pki);
_notifier.update(originPki, pki);
} else {
PrivateKeyProvider.add(pki);
_notifier.add(pki);
}
} catch (e) {
context.showSnackBar(e.toString());

View File

@@ -3,22 +3,23 @@ import 'dart:io';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/model/server/private_key_info.dart';
import 'package:server_box/data/provider/private_key.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/view/page/private_key/edit.dart';
class PrivateKeysListPage extends StatefulWidget {
class PrivateKeysListPage extends ConsumerStatefulWidget {
const PrivateKeysListPage({super.key});
@override
State<PrivateKeysListPage> createState() => _PrivateKeyListState();
ConsumerState<PrivateKeysListPage> createState() => _PrivateKeyListState();
static const route = AppRouteNoArg(page: PrivateKeysListPage.new, path: '/private_key');
}
class _PrivateKeyListState extends State<PrivateKeysListPage> with AfterLayoutMixin {
class _PrivateKeyListState extends ConsumerState<PrivateKeysListPage> with AfterLayoutMixin {
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -31,14 +32,15 @@ class _PrivateKeyListState extends State<PrivateKeysListPage> with AfterLayoutMi
}
Widget _buildBody() {
return PrivateKeyProvider.pkis.listenVal((pkis) {
if (pkis.isEmpty) {
return Center(child: Text(libL10n.empty));
}
final privateKeyState = ref.watch(privateKeyNotifierProvider);
final pkis = privateKeyState.keys;
if (pkis.isEmpty) {
return Center(child: Text(libL10n.empty));
}
final children = pkis.map(_buildKeyItem).toList();
return AutoMultiList(children: children);
});
final children = pkis.map(_buildKeyItem).toList();
return AutoMultiList(children: children);
}
Widget _buildKeyItem(PrivateKeyInfo item) {

View File

@@ -3,25 +3,26 @@ import 'dart:async';
import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/route.dart';
import 'package:server_box/data/model/app/scripts/shell_func.dart';
import 'package:server_box/data/model/server/proc.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/provider/server/single.dart';
import 'package:server_box/data/res/store.dart';
class ProcessPage extends StatefulWidget {
class ProcessPage extends ConsumerStatefulWidget {
final SpiRequiredArgs args;
const ProcessPage({super.key, required this.args});
@override
State<ProcessPage> createState() => _ProcessPageState();
ConsumerState<ProcessPage> createState() => _ProcessPageState();
static const route = AppRouteArg(page: ProcessPage.new, path: '/process');
}
class _ProcessPageState extends State<ProcessPage> {
class _ProcessPageState extends ConsumerState<ProcessPage> {
late Timer _timer;
late MediaQueryData _media;
@@ -36,6 +37,8 @@ class _ProcessPageState extends State<ProcessPage> {
ProcSortMode _procSortMode = ProcSortMode.cpu;
List<ProcSortMode> _sortModes = List.from(ProcSortMode.values);
late final _provider = serverNotifierProvider(widget.args.spi.id);
@override
void dispose() {
super.dispose();
@@ -45,7 +48,8 @@ class _ProcessPageState extends State<ProcessPage> {
@override
void initState() {
super.initState();
_client = widget.args.spi.server?.value.client;
final serverState = ref.read(_provider);
_client = serverState.client;
final duration = Duration(seconds: Stores.setting.serverStatusUpdateInterval.fetch());
_timer = Timer.periodic(duration, (_) => _refresh());
}
@@ -58,9 +62,10 @@ class _ProcessPageState extends State<ProcessPage> {
Future<void> _refresh() async {
if (mounted) {
final systemType = widget.args.spi.server?.value.status.system;
final serverState = ref.read(_provider);
final systemType = serverState.status.system;
final result = await _client
?.run(ShellFunc.process.exec(widget.args.spi.id, systemType: systemType))
?.run(ShellFunc.process.exec(widget.args.spi.id, systemType: systemType, customDir: null))
.string;
if (result == null || result.isEmpty) {
context.showSnackBar(libL10n.empty);

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/model/server/pve.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
@@ -15,29 +16,30 @@ final class PvePageArgs {
const PvePageArgs({required this.spi});
}
final class PvePage extends StatefulWidget {
final class PvePage extends ConsumerStatefulWidget {
final PvePageArgs args;
const PvePage({super.key, required this.args});
@override
State<PvePage> createState() => _PvePageState();
ConsumerState<PvePage> createState() => _PvePageState();
static const route = AppRouteArg<void, PvePageArgs>(page: PvePage.new, path: '/pve');
}
const _kHorziPadding = 11.0;
final class _PvePageState extends State<PvePage> {
late final _pve = PveProvider(spi: widget.args.spi);
final class _PvePageState extends ConsumerState<PvePage> {
late MediaQueryData _media;
Timer? _timer;
late final _provider = pveNotifierProvider(widget.args.spi);
late final _notifier = ref.read(_provider.notifier);
@override
void dispose() {
super.dispose();
_timer?.cancel();
_pve.dispose();
}
@override
@@ -55,43 +57,34 @@ final class _PvePageState extends State<PvePage> {
@override
Widget build(BuildContext context) {
final pveState = ref.watch(_provider);
// If there is an error, stop the timer
if (pveState.error != null) {
_timer?.cancel();
}
return Scaffold(
appBar: CustomAppBar(
title: TwoLineText(up: 'PVE', down: widget.args.spi.name),
actions: [
ValBuilder(
listenable: _pve.err,
builder: (val) => val == null
? UIs.placeholder
: Btn.icon(
icon: const Icon(Icons.refresh),
onTap: () {
_pve.err.value = null;
_pve.list();
_initRefreshTimer();
},
),
),
pveState.error == null
? UIs.placeholder
: Btn.icon(
icon: const Icon(Icons.refresh),
onTap: () {
_notifier.list();
_initRefreshTimer();
},
),
],
),
body: ValBuilder(
listenable: _pve.err,
builder: (val) {
if (val != null) {
_timer?.cancel();
return Padding(
body: pveState.error != null
? Padding(
padding: const EdgeInsets.all(13),
child: Center(child: Text(val)),
);
}
return ValBuilder(
listenable: _pve.data,
builder: (val) {
return _buildBody(val);
},
);
},
),
child: Center(child: Text(pveState.error.toString())),
)
: _buildBody(pveState.data),
);
}
@@ -342,7 +335,7 @@ final class _PvePageState extends State<PvePage> {
if (!item.available) {
return Btn.icon(
icon: const Icon(Icons.play_arrow, color: Colors.grey),
onTap: () => _onCtrl(_pve.start, l10n.start, item),
onTap: () => _onCtrl(l10n.start, item, () => _notifier.start(item.node, item.id)),
);
}
return Row(
@@ -350,17 +343,17 @@ final class _PvePageState extends State<PvePage> {
Btn.icon(
icon: const Icon(Icons.stop, color: Colors.grey, size: 20),
padding: pad,
onTap: () => _onCtrl(_pve.stop, l10n.stop, item),
onTap: () => _onCtrl(l10n.stop, item, () => _notifier.stop(item.node, item.id)),
),
Btn.icon(
icon: const Icon(Icons.refresh, color: Colors.grey, size: 20),
padding: pad,
onTap: () => _onCtrl(_pve.reboot, l10n.reboot, item),
onTap: () => _onCtrl(l10n.reboot, item, () => _notifier.reboot(item.node, item.id)),
),
Btn.icon(
icon: const Icon(Icons.power_off, color: Colors.grey, size: 20),
padding: pad,
onTap: () => _onCtrl(_pve.shutdown, l10n.shutdown, item),
onTap: () => _onCtrl(l10n.shutdown, item, () => _notifier.shutdown(item.node, item.id)),
),
],
);
@@ -368,7 +361,7 @@ final class _PvePageState extends State<PvePage> {
}
extension on _PvePageState {
void _onCtrl(PveCtrlFunc func, String action, PveCtrlIface item) async {
void _onCtrl(String action, PveCtrlIface item, Future<bool> Function() func) async {
final sure = await context.showRoundDialog<bool>(
title: libL10n.attention,
child: Text(libL10n.askContinue('$action ${item.id}')),
@@ -376,7 +369,7 @@ extension on _PvePageState {
);
if (sure != true) return;
final (suc, err) = await context.showLoadingDialog(fn: () => func(item.node, item.id));
final (suc, err) = await context.showLoadingDialog(fn: func);
if (suc == true) {
context.showSnackBar(libL10n.success);
} else {
@@ -384,28 +377,40 @@ extension on _PvePageState {
}
}
/// Add PveNode if [PveProvider.onlyOneNode] is false
/// Add PveNode if only one node exists
String _wrapNodeName(PveCtrlIface item) {
if (_pve.onlyOneNode) {
final pveState = ref.read(_provider);
if (pveState.data?.onlyOneNode ?? false) {
return item.name;
}
return '${item.node} / ${item.name}';
}
void _initRefreshTimer() {
_timer?.cancel();
_timer = Timer.periodic(Duration(seconds: Stores.setting.serverStatusUpdateInterval.fetch()), (_) {
if (mounted) {
_pve.list();
_notifier.list();
}
});
}
void _afterInit() async {
await _pve.connected.future;
if (_pve.release != null && _pve.release!.compareTo('8.0') < 0) {
if (mounted) {
context.showSnackBar(l10n.pveVersionLow);
// Wait for the PVE state to be connected
while (mounted) {
final pveState = ref.read(_provider);
if (pveState.isConnected) {
if (pveState.release != null && pveState.release!.compareTo('8.0') < 0) {
if (mounted) {
context.showSnackBar(l10n.pveVersionLow);
}
}
break;
}
if (pveState.error != null) {
break; // Skip if there is an error
}
await Future.delayed(const Duration(milliseconds: 100));
}
}
}

View File

@@ -3,8 +3,10 @@ import 'package:fl_chart/fl_chart.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/extension/server.dart';
import 'package:server_box/core/route.dart';
import 'package:server_box/data/model/app/scripts/cmd_types.dart';
import 'package:server_box/data/model/app/server_detail_card.dart';
@@ -16,28 +18,28 @@ import 'package:server_box/data/model/server/disk_smart.dart';
import 'package:server_box/data/model/server/net_speed.dart';
import 'package:server_box/data/model/server/nvdia.dart';
import 'package:server_box/data/model/server/sensors.dart';
import 'package:server_box/data/model/server/server.dart';
import 'package:server_box/data/model/server/server.dart' as server_model;
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/provider/server/single.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/view/page/pve.dart';
import 'package:server_box/view/page/server/edit.dart';
import 'package:server_box/view/page/server/logo.dart';
import 'package:server_box/view/widget/server_func_btns.dart';
part 'misc.dart';
class ServerDetailPage extends StatefulWidget {
class ServerDetailPage extends ConsumerStatefulWidget {
final SpiRequiredArgs args;
const ServerDetailPage({super.key, required this.args});
@override
State<ServerDetailPage> createState() => _ServerDetailPageState();
ConsumerState<ServerDetailPage> createState() => _ServerDetailPageState();
static const route = AppRouteArg(page: ServerDetailPage.new, path: '/servers/detail');
}
class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerProviderStateMixin {
class _ServerDetailPageState extends ConsumerState<ServerDetailPage> with SingleTickerProviderStateMixin {
late final _cardBuildMap = Map.fromIterables(ServerDetailCards.names, [
_buildAbout,
_buildCPUView,
@@ -84,17 +86,17 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
@override
Widget build(BuildContext context) {
final s = widget.args.spi.server;
if (s == null) {
final serverState = ref.watch(serverNotifierProvider(widget.args.spi.id));
if (serverState.client == null) {
return Scaffold(
appBar: CustomAppBar(),
body: Center(child: Text(libL10n.empty)),
);
}
return s.listenVal(_buildMainPage);
return _buildMainPage(serverState);
}
Widget _buildMainPage(Server si) {
Widget _buildMainPage(ServerState si) {
final buildFuncs = !Stores.setting.moveServerFuncs.fetch();
final logo = _buildLogo(si);
final children = <Widget>[if (logo != null) logo, if (buildFuncs) ServerFuncBtns(spi: si.spi)];
@@ -111,7 +113,7 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
);
}
CustomAppBar _buildAppBar(Server si) {
CustomAppBar _buildAppBar(ServerState si) {
return CustomAppBar(
title: Text(
si.spi.name,
@@ -132,7 +134,7 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
);
}
Widget? _buildLogo(Server si) {
Widget? _buildLogo(ServerState si) {
final logoUrl = si.getLogoUrl(context);
return Padding(
@@ -153,7 +155,7 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
);
}
Widget? _buildAbout(Server si) {
Widget? _buildAbout(ServerState si) {
final ss = si.status;
return ExpandTile(
key: ValueKey(ss.more.hashCode), // Use hashCode to avoid perf issue
@@ -178,7 +180,7 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
).cardx;
}
Widget? _buildCPUView(Server si) {
Widget? _buildCPUView(ServerState si) {
final ss = si.status;
final percent = ss.cpu.usedPercent(coreIdx: 0).toInt();
final details = [
@@ -305,7 +307,7 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
return children;
}
Widget _buildCPUChart(ServerStatus ss) {
Widget _buildCPUChart(server_model.ServerStatus ss) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 13),
child: LayoutBuilder(
@@ -335,7 +337,7 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
);
}
Widget? _buildMemView(Server si) {
Widget? _buildMemView(ServerState si) {
final ss = si.status;
final free = ss.mem.free / ss.mem.total * 100;
final avail = ss.mem.availPercent * 100;
@@ -376,7 +378,7 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
).cardx;
}
Widget? _buildSwapView(Server si) {
Widget? _buildSwapView(ServerState si) {
final ss = si.status;
if (ss.swap.total == 0) return null;
@@ -408,7 +410,7 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
).cardx;
}
Widget? _buildGpuView(Server si) {
Widget? _buildGpuView(ServerState si) {
final ss = si.status;
final hasNvidia = ss.nvidia != null && ss.nvidia!.isNotEmpty;
final hasAmd = ss.amd != null && ss.amd!.isNotEmpty;
@@ -532,7 +534,7 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
);
}
Widget? _buildDiskView(Server si) {
Widget? _buildDiskView(ServerState si) {
final ss = si.status;
final children = <Widget>[];
@@ -553,7 +555,7 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
).cardx;
}
Widget _buildDiskItemWithHierarchy(Disk disk, ServerStatus ss, int depth) {
Widget _buildDiskItemWithHierarchy(Disk disk, server_model.ServerStatus ss, int depth) {
// Create a list to hold this disk and its children
final items = <Widget>[];
@@ -570,7 +572,7 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
return Column(children: items);
}
Widget _buildDiskItem(Disk disk, ServerStatus ss, int depth) {
Widget _buildDiskItem(Disk disk, server_model.ServerStatus ss, int depth) {
final (read, write) = ss.diskIO.getSpeed(disk.path);
final text = () {
final use = '${l10n.used} ${disk.used.kb2Str} / ${disk.size.kb2Str}';
@@ -625,7 +627,7 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
);
}
Widget? _buildDiskSmart(Server si) {
Widget? _buildDiskSmart(ServerState si) {
final smarts = si.status.diskSmart;
if (smarts.isEmpty) return null;
return CardX(
@@ -770,7 +772,7 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
);
}
Widget? _buildNetView(Server si) {
Widget? _buildNetView(ServerState si) {
final ss = si.status;
final ns = ss.netSpeed;
final children = <Widget>[];
@@ -847,7 +849,7 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
);
}
Widget? _buildTemperature(Server si) {
Widget? _buildTemperature(ServerState si) {
final ss = si.status;
if (ss.temps.isEmpty) return null;
@@ -879,7 +881,7 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
);
}
Widget? _buildBatteries(Server si) {
Widget? _buildBatteries(ServerState si) {
final ss = si.status;
if (ss.batteries.isEmpty) return null;
@@ -914,7 +916,7 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
);
}
Widget? _buildSensors(Server si) {
Widget? _buildSensors(ServerState si) {
final ss = si.status;
if (ss.sensors.isEmpty) return UIs.placeholder;
return CardX(
@@ -967,7 +969,7 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
);
}
Widget? _buildPve(Server si) {
Widget? _buildPve(ServerState si) {
final addr = si.spi.custom?.pveAddr;
if (addr == null || addr.isEmpty) return null;
return CardX(
@@ -980,7 +982,7 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
);
}
Widget? _buildCustomCmd(Server si) {
Widget? _buildCustomCmd(ServerState si) {
final ss = si.status;
if (ss.customCmds.isEmpty) return null;
return CardX(

View File

@@ -1,23 +1,28 @@
import 'dart:convert';
import 'dart:io';
import 'package:choice/choice.dart';
import 'package:file_picker/file_picker.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/route.dart';
import 'package:server_box/core/utils/server_dedup.dart';
import 'package:server_box/core/utils/ssh_config.dart';
import 'package:server_box/data/model/app/scripts/cmd_types.dart';
import 'package:server_box/data/model/server/custom.dart';
import 'package:server_box/data/model/server/server.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/model/server/wol_cfg.dart';
import 'package:server_box/data/provider/private_key.dart';
import 'package:server_box/data/provider/server.dart';
import 'package:server_box/data/provider/server/all.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/data/store/server.dart';
import 'package:server_box/view/page/private_key/edit.dart';
class ServerEditPage extends StatefulWidget {
class ServerEditPage extends ConsumerStatefulWidget {
final SpiRequiredArgs? args;
const ServerEditPage({super.key, this.args});
@@ -25,10 +30,10 @@ class ServerEditPage extends StatefulWidget {
static const route = AppRoute<bool, SpiRequiredArgs>(page: ServerEditPage.new, path: '/servers/edit');
@override
State<ServerEditPage> createState() => _ServerEditPageState();
ConsumerState<ServerEditPage> createState() => _ServerEditPageState();
}
class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
class _ServerEditPageState extends ConsumerState<ServerEditPage> with AfterLayoutMixin {
late final spi = widget.args?.spi;
final _nameController = TextEditingController();
final _ipController = TextEditingController();
@@ -121,7 +126,7 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
}
Widget _buildForm() {
final topItems = [_buildWriteScriptTip(), if (isMobile) _buildQrScan()];
final topItems = [_buildWriteScriptTip(), if (isMobile) _buildQrScan(), if (isDesktop) _buildSSHImport()];
final children = [
Row(mainAxisAlignment: MainAxisAlignment.center, children: topItems.joinWith(UIs.width13).toList()),
Input(
@@ -167,7 +172,7 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
hint: 'root',
suggestion: false,
),
TagTile(tags: _tags, allTags: ServerProvider.tags.value).cardx,
TagTile(tags: _tags, allTags: ref.watch(serversNotifierProvider).tags).cardx,
ListTile(
title: Text(l10n.autoConnect),
trailing: _autoConnect.listenVal(
@@ -227,34 +232,35 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
}
Widget _buildKeyAuth() {
return PrivateKeyProvider.pkis.listenVal((pkis) {
final tiles = List<Widget>.generate(pkis.length, (index) {
final e = pkis[index];
return ListTile(
contentPadding: const EdgeInsets.only(left: 10, right: 15),
leading: Radio<int>(value: index),
title: Text(e.id, textAlign: TextAlign.start),
subtitle: Text(e.type ?? l10n.unknown, textAlign: TextAlign.start, style: UIs.textGrey),
trailing: Btn.icon(
icon: const Icon(Icons.edit),
onTap: () => PrivateKeyEditPage.route.go(context, args: PrivateKeyEditPageArgs(pki: e)),
),
onTap: () => _keyIdx.value = index,
);
});
tiles.add(
ListTile(
title: Text(libL10n.add),
contentPadding: const EdgeInsets.only(left: 23, right: 23),
trailing: const Icon(Icons.add),
onTap: () => PrivateKeyEditPage.route.go(context),
final privateKeyState = ref.watch(privateKeyNotifierProvider);
final pkis = privateKeyState.keys;
final tiles = List<Widget>.generate(pkis.length, (index) {
final e = pkis[index];
return ListTile(
contentPadding: const EdgeInsets.only(left: 10, right: 15),
leading: Radio<int>(value: index),
title: Text(e.id, textAlign: TextAlign.start),
subtitle: Text(e.type ?? l10n.unknown, textAlign: TextAlign.start, style: UIs.textGrey),
trailing: Btn.icon(
icon: const Icon(Icons.edit),
onTap: () => PrivateKeyEditPage.route.go(context, args: PrivateKeyEditPageArgs(pki: e)),
),
);
return RadioGroup<int>(
onChanged: (val) => _keyIdx.value = val,
child: _keyIdx.listenVal((_) => Column(children: tiles)).cardx,
onTap: () => _keyIdx.value = index,
);
});
tiles.add(
ListTile(
title: Text(libL10n.add),
contentPadding: const EdgeInsets.only(left: 23, right: 23),
trailing: const Icon(Icons.add),
onTap: () => PrivateKeyEditPage.route.go(context),
),
);
return RadioGroup<int>(
onChanged: (val) => _keyIdx.value = val,
child: _keyIdx.listenVal((_) => Column(children: tiles)).cardx,
);
}
Widget _buildEnvs() {
@@ -485,27 +491,29 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
Widget _buildJumpServer() {
const padding = EdgeInsets.only(left: 13, right: 13, bottom: 7);
final srvs = ServerProvider.servers.values
.map((e) => e.value)
.where((e) => e.spi.jumpId == null)
.where((e) => e.spi.id != spi?.id)
final srvs = ref
.watch(serversNotifierProvider)
.servers
.values
.where((e) => e.jumpId == null)
.where((e) => e.id != spi?.id)
.toList();
final choice = _jumpServer.listenVal((val) {
final srv = srvs.firstWhereOrNull((e) => e.id == _jumpServer.value);
return Choice<Server>(
return Choice<Spi>(
multiple: false,
clearable: true,
value: srv != null ? [srv] : [],
builder: (state, _) => Wrap(
children: List<Widget>.generate(srvs.length, (index) {
final item = srvs[index];
return ChoiceChipX<Server>(
label: item.spi.name,
return ChoiceChipX<Spi>(
label: item.name,
state: state,
value: item,
onSelected: (srv, on) {
if (on) {
_jumpServer.value = srv.spi.id;
_jumpServer.value = srv.id;
} else {
_jumpServer.value = null;
}
@@ -560,6 +568,16 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
);
}
Widget _buildSSHImport() {
return Btn.tile(
text: l10n.sshConfigImport,
icon: const Icon(Icons.settings, color: Colors.grey),
onTap: _onTapSSHImport,
textStyle: UIs.textGrey,
mainAxisSize: MainAxisSize.min,
);
}
Widget _buildDelBtn() {
return IconButton(
onPressed: () {
@@ -569,7 +587,7 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
actions: Btn.ok(
onTap: () async {
context.pop();
ServerProvider.delServer(spi!.id);
ref.read(serversNotifierProvider.notifier).delServer(spi!.id);
context.pop(true);
},
red: true,
@@ -584,11 +602,115 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
void afterFirstLayout(BuildContext context) {
if (spi != null) {
_initWithSpi(spi!);
} else {
// Only for new servers, check SSH config import on first time
_checkSSHConfigImport();
}
}
}
extension on _ServerEditPageState {
extension _Actions on _ServerEditPageState {
void _onTapSSHImport() async {
try {
final servers = await SSHConfig.parseConfig();
if (servers.isEmpty) {
context.showSnackBar(l10n.sshConfigNoServers);
return;
}
dprint('Parsed ${servers.length} servers from SSH config');
await _processSSHServers(servers);
dprint('Finished processing SSH config servers');
} catch (e, s) {
_handleImportSSHCfgPermissionIssue(e, s);
}
}
void _handleImportSSHCfgPermissionIssue(Object e, StackTrace s) async {
dprint('Error importing SSH config: $e');
// Check if it's a permission error and offer file picker as fallback
if (e is PathAccessException || e.toString().contains('Operation not permitted')) {
final useFilePicker = await context.showRoundDialog<bool>(
title: l10n.sshConfigImport,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.sshConfigPermissionDenied),
const SizedBox(height: 8),
Text(l10n.sshConfigManualSelect),
],
),
actions: Btnx.cancelOk,
);
if (useFilePicker == true) {
await _onTapSSHImportWithFilePicker();
}
} else {
context.showErrDialog(e, s);
}
}
Future<void> _processSSHServers(List<Spi> servers) async {
final deduplicated = ServerDeduplication.deduplicateServers(servers);
final resolved = ServerDeduplication.resolveNameConflicts(deduplicated);
final summary = ServerDeduplication.getImportSummary(servers, resolved);
if (!summary.hasItemsToImport) {
context.showSnackBar(l10n.sshConfigAllExist('${summary.duplicates}'));
return;
}
final shouldImport = await context.showRoundDialog<bool>(
title: l10n.sshConfigImport,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.sshConfigFoundServers('${summary.total}')),
if (summary.hasDuplicates)
Text(l10n.sshConfigDuplicatesSkipped('${summary.duplicates}'), style: UIs.textGrey),
Text(l10n.sshConfigServersToImport('${summary.toImport}')),
const SizedBox(height: 16),
...resolved.map((s) => Text('${s.name} (${s.user}@${s.ip}:${s.port})')),
],
),
),
actions: Btnx.cancelOk,
);
if (shouldImport == true) {
for (final server in resolved) {
ref.read(serversNotifierProvider.notifier).addServer(server);
}
context.showSnackBar(l10n.sshConfigImported('${resolved.length}'));
}
}
Future<void> _onTapSSHImportWithFilePicker() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.any,
allowMultiple: false,
dialogTitle: 'SSH ${libL10n.select}',
);
if (result?.files.single.path case final path?) {
final servers = await SSHConfig.parseConfig(path);
if (servers.isEmpty) {
context.showSnackBar(l10n.sshConfigNoServers);
return;
}
await _processSSHServers(servers);
}
} catch (e, s) {
context.showErrDialog(e, s);
}
}
void _onTapCustomItem() async {
final res = await KvEditor.route.go(context, KvEditorArgs(data: _customCmds.value));
if (res == null) return;
@@ -604,52 +726,6 @@ extension on _ServerEditPageState {
await _showCmdTypesDialog(allCmdTypes);
}
Future<void> _showCmdTypesDialog(Set<ShellCmdType> allCmdTypes) {
return context.showRoundDialog(
title: '${libL10n.disabled} ${l10n.cmd}',
child: SizedBox(
width: 270,
child: _disabledCmdTypes.listenVal((disabled) {
return ListView.builder(
itemCount: allCmdTypes.length,
itemExtent: 50,
itemBuilder: (context, index) {
final cmdType = allCmdTypes.elementAtOrNull(index);
if (cmdType == null) return UIs.placeholder;
final display = cmdType.displayName;
return ListTile(
leading: Icon(cmdType.sysType.icon, size: 20),
title: Text(cmdType.name, style: const TextStyle(fontSize: 16)),
trailing: Checkbox(
value: disabled.contains(display),
onChanged: (value) {
if (value == null) return;
if (value) {
_disabledCmdTypes.value.add(display);
} else {
_disabledCmdTypes.value.remove(display);
}
_disabledCmdTypes.notify();
},
),
onTap: () {
final isDisabled = disabled.contains(display);
if (isDisabled) {
_disabledCmdTypes.value.remove(display);
} else {
_disabledCmdTypes.value.add(display);
}
_disabledCmdTypes.notify();
},
);
},
);
}),
),
actions: Btnx.oks,
);
}
void _onSave() async {
if (_ipController.text.isEmpty) {
context.showSnackBar('${libL10n.empty} ${l10n.host}');
@@ -705,7 +781,9 @@ extension on _ServerEditPageState {
port: int.parse(_portController.text),
user: _usernameController.text,
pwd: _passwordController.text.selfNotEmptyOrNull,
keyId: _keyIdx.value != null ? PrivateKeyProvider.pkis.value.elementAt(_keyIdx.value!).id : null,
keyId: _keyIdx.value != null
? ref.read(privateKeyNotifierProvider).keys.elementAt(_keyIdx.value!).id
: null,
tags: _tags.value.isEmpty ? null : _tags.value.toList(),
alterUrl: _altUrlController.text.selfNotEmptyOrNull,
autoConnect: _autoConnect.value,
@@ -724,13 +802,118 @@ extension on _ServerEditPageState {
context.showSnackBar('${l10n.sameIdServerExist}: ${spi.id}');
return;
}
ServerProvider.addServer(spi);
ref.read(serversNotifierProvider.notifier).addServer(spi);
} else {
ServerProvider.updateServer(this.spi!, spi);
ref.read(serversNotifierProvider.notifier).updateServer(this.spi!, spi);
}
context.pop();
}
}
extension _Utils on _ServerEditPageState {
void _checkSSHConfigImport() async {
final prop = Stores.setting.firstTimeReadSSHCfg;
// Only check if it's first time and user hasn't disabled it
if (!prop.fetch()) return;
try {
// Check if SSH config exists
final (_, configExists) = SSHConfig.configExists();
if (!configExists) return;
// Ask for permission
final hasPermission = await context.showRoundDialog<bool>(
title: l10n.sshConfigImport,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.sshConfigFound),
UIs.height7,
Text(l10n.sshConfigImportPermission),
UIs.height7,
Text(l10n.sshConfigImportHelp, style: UIs.textGrey),
],
),
actions: Btnx.cancelOk,
);
prop.put(false);
if (hasPermission == true) {
// Parse and import SSH config
final servers = await SSHConfig.parseConfig();
if (servers.isEmpty) {
context.showSnackBar(l10n.sshConfigNoServers);
return;
}
final deduplicated = ServerDeduplication.deduplicateServers(servers);
final resolved = ServerDeduplication.resolveNameConflicts(deduplicated);
final summary = ServerDeduplication.getImportSummary(servers, resolved);
if (!summary.hasItemsToImport) {
context.showSnackBar(l10n.sshConfigAllExist('${summary.duplicates}'));
return;
}
// Import without asking again since user already gave permission
for (final server in resolved) {
ref.read(serversNotifierProvider.notifier).addServer(server);
}
context.showSnackBar(l10n.sshConfigImported('${resolved.length}'));
}
} catch (e, s) {
_handleImportSSHCfgPermissionIssue(e, s);
}
}
Future<void> _showCmdTypesDialog(Set<ShellCmdType> allCmdTypes) {
return context.showRoundDialog(
title: '${libL10n.disabled} ${l10n.cmd}',
child: SizedBox(
width: 270,
child: _disabledCmdTypes.listenVal((disabled) {
return ListView.builder(
itemCount: allCmdTypes.length,
itemExtent: 50,
itemBuilder: (context, index) {
final cmdType = allCmdTypes.elementAtOrNull(index);
if (cmdType == null) return UIs.placeholder;
final display = cmdType.displayName;
return ListTile(
leading: Icon(cmdType.sysType.icon, size: 20),
title: Text(cmdType.name, style: const TextStyle(fontSize: 16)),
trailing: Checkbox(
value: disabled.contains(display),
onChanged: (value) {
if (value == null) return;
if (value) {
_disabledCmdTypes.value.add(display);
} else {
_disabledCmdTypes.value.remove(display);
}
_disabledCmdTypes.notify();
},
),
onTap: () {
final isDisabled = disabled.contains(display);
if (isDisabled) {
_disabledCmdTypes.value.remove(display);
} else {
_disabledCmdTypes.value.add(display);
}
_disabledCmdTypes.notify();
},
);
},
);
}),
),
actions: Btnx.oks,
);
}
void _initWithSpi(Spi spi) {
_nameController.text = spi.name;
@@ -740,7 +923,7 @@ extension on _ServerEditPageState {
if (spi.keyId == null) {
_passwordController.text = spi.pwd ?? '';
} else {
_keyIdx.value = PrivateKeyProvider.pkis.value.indexWhere((e) => e.id == spi.keyId);
_keyIdx.value = ref.read(privateKeyNotifierProvider).keys.indexWhere((e) => e.id == spi.keyId);
}
/// List in dart is passed by pointer, so you need to copy it here

View File

@@ -1,7 +1,7 @@
part of 'tab.dart';
extension on _ServerPageState {
Widget _buildServerCardTitle(Server s) {
Widget _buildServerCardTitle(ServerState s) {
return Padding(
padding: const EdgeInsets.only(left: 7, right: 13),
child: Row(
@@ -17,12 +17,12 @@ extension on _ServerPageState {
);
}
Widget _buildTopRightWidget(Server s) {
Widget _buildTopRightWidget(ServerState s) {
final (child, onTap) = switch (s.conn) {
ServerConn.connecting || ServerConn.loading || ServerConn.connected => (
SizedBox.square(
dimension: _ServerPageState._kCardHeightMin,
child: SizedLoading(_ServerPageState._kCardHeightMin, strokeWidth: 3, padding: 3),
child: SizedLoading(_ServerPageState._kCardHeightMin, padding: 3),
),
null,
),
@@ -30,16 +30,16 @@ extension on _ServerPageState {
const Icon(Icons.refresh, size: 21, color: Colors.grey),
() {
TryLimiter.reset(s.spi.id);
ServerProvider.refresh(spi: s.spi);
ref.read(serversNotifierProvider.notifier).refresh(spi: s.spi);
},
),
ServerConn.disconnected => (
const Icon(MingCute.link_3_line, size: 19, color: Colors.grey),
() => ServerProvider.refresh(spi: s.spi),
() => ref.read(serversNotifierProvider.notifier).refresh(spi: s.spi),
),
ServerConn.finished => (
const Icon(MingCute.unlink_2_line, size: 17, color: Colors.grey),
() => ServerProvider.closeServer(id: s.spi.id),
() => ref.read(serversNotifierProvider.notifier).closeServer(id: s.spi.id),
),
};
@@ -51,7 +51,7 @@ extension on _ServerPageState {
return InkWell(borderRadius: BorderRadius.circular(7), onTap: onTap, child: wrapped).paddingOnly(left: 5);
}
Widget _buildTopRightText(Server s) {
Widget _buildTopRightText(ServerState s) {
final hasErr = s.status.err != null;
final str = s._getTopRightStr(s.spi);
if (str == null) return UIs.placeholder;
@@ -106,7 +106,7 @@ ${ss.err?.message ?? 'null'}
Widget _buildNet(ServerStatus ss, String id) {
final cardNoti = _getCardNoti(id);
final type = cardNoti.value.net ?? Stores.setting.netViewType.fetch();
final device = ServerProvider.pick(id: id)?.value.spi.custom?.netDev;
final device = ref.watch(serversNotifierProvider).servers[id]?.custom?.netDev;
final (a, b) = type.build(ss, dev: device);
return AnimatedSwitcher(
duration: const Duration(milliseconds: 377),

View File

@@ -26,33 +26,31 @@ extension on _ServerPageState {
}
Widget _buildLandscapeBody() {
return ServerProvider.serverOrder.listenVal((order) {
if (order.isEmpty) {
return Center(child: Text(libL10n.empty, textAlign: TextAlign.center));
}
final serverState = ref.watch(serversNotifierProvider);
final order = serverState.serverOrder;
if (order.isEmpty) {
return Center(child: Text(libL10n.empty, textAlign: TextAlign.center));
}
return PageView.builder(
itemCount: order.length,
itemBuilder: (_, idx) {
final id = order[idx];
final srv = ServerProvider.pick(id: id);
if (srv == null) return UIs.placeholder;
return PageView.builder(
itemCount: order.length,
itemBuilder: (_, idx) {
final id = order[idx];
final srv = ref.watch(serverNotifierProvider(id));
return srv.listenVal((srv) {
final title = _buildServerCardTitle(srv);
final List<Widget> children = [title, _buildNormalCard(srv.status, srv.spi)];
final title = _buildServerCardTitle(srv);
final List<Widget> children = [title, _buildNormalCard(srv.status, srv.spi)];
return _getCardNoti(id).listenVal((_) {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: children,
);
});
});
},
);
});
return _getCardNoti(id).listenVal((_) {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: children,
);
});
},
);
}
}

View File

@@ -5,6 +5,7 @@ import 'dart:math' as math;
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:server_box/core/extension/context/locale.dart';
@@ -16,7 +17,8 @@ import 'package:server_box/data/model/app/scripts/shell_func.dart';
import 'package:server_box/data/model/server/server.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/try_limiter.dart';
import 'package:server_box/data/provider/server.dart';
import 'package:server_box/data/provider/server/all.dart';
import 'package:server_box/data/provider/server/single.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/detail/view.dart';
@@ -31,11 +33,11 @@ part 'landscape.dart';
part 'top_bar.dart';
part 'utils.dart';
class ServerPage extends StatefulWidget {
class ServerPage extends ConsumerStatefulWidget {
const ServerPage({super.key});
@override
State<ServerPage> createState() => _ServerPageState();
ConsumerState<ServerPage> createState() => _ServerPageState();
static const route = AppRouteNoArg(page: ServerPage.new, path: '/servers');
}
@@ -43,12 +45,14 @@ class ServerPage extends StatefulWidget {
const _cardPad = 74.0;
const _cardPadSingle = 13.0;
class _ServerPageState extends State<ServerPage> with AutomaticKeepAliveClientMixin, AfterLayoutMixin {
class _ServerPageState extends ConsumerState<ServerPage>
with AutomaticKeepAliveClientMixin, AfterLayoutMixin {
late double _textFactorDouble;
double _offset = 1;
late TextScaler _textFactor;
final _cardsStatus = <String, _CardNotifier>{};
late final ValueNotifier<Set<String>> _tags;
Timer? _timer;
@@ -64,11 +68,13 @@ class _ServerPageState extends State<ServerPage> with AutomaticKeepAliveClientMi
_scrollController.dispose();
_autoHideCtrl.dispose();
_tag.dispose();
_tags.dispose();
}
@override
void initState() {
super.initState();
_tags = ValueNotifier(ref.read(serversNotifierProvider).tags);
_startAvoidJitterTimer();
}
@@ -78,9 +84,14 @@ class _ServerPageState extends State<ServerPage> with AutomaticKeepAliveClientMi
_updateOffset();
}
@override
Widget build(BuildContext context) {
super.build(context);
// Listen to provider changes and update the ValueNotifier
ref.listen(serversNotifierProvider, (previous, next) {
_tags.value = next.tags;
});
return OrientationBuilder(
builder: (_, orientation) {
if (orientation == Orientation.landscape) {
@@ -96,7 +107,7 @@ class _ServerPageState extends State<ServerPage> with AutomaticKeepAliveClientMi
Widget _buildScaffold(Widget child) {
return Scaffold(
appBar: _TopBar(tags: ServerProvider.tags, onTagChanged: (p0) => _tag.value = p0, initTag: _tag.value),
appBar: _TopBar(tags: _tags, onTagChanged: (p0) => _tag.value = p0, initTag: _tag.value),
body: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _autoHideCtrl.show,
@@ -122,22 +133,21 @@ class _ServerPageState extends State<ServerPage> with AutomaticKeepAliveClientMi
Widget _buildPortrait() {
// final isMobile = ResponsiveBreakpoints.of(context).isMobile;
return ServerProvider.serverOrder.listenVal((order) {
return _tag.listenVal((val) {
final filtered = _filterServers(order);
final child = _buildScaffold(_buildBodySmall(filtered: filtered));
// if (isMobile) {
return child;
// }
final serverState = ref.watch(serversNotifierProvider);
return _tag.listenVal((val) {
final filtered = _filterServers(serverState.serverOrder);
final child = _buildScaffold(_buildBodySmall(filtered: filtered));
// if (isMobile) {
return child;
// }
// return SplitView(
// controller: _splitViewCtrl,
// leftWeight: 1,
// rightWeight: 1.3,
// initialRight: Center(child: CircularProgressIndicator()),
// leftBuilder: (_, __) => child,
// );
});
// return SplitView(
// controller: _splitViewCtrl,
// leftWeight: 1,
// rightWeight: 1.3,
// initialRight: Center(child: CircularProgressIndicator()),
// leftBuilder: (_, __) => child,
// );
});
}
@@ -173,10 +183,9 @@ class _ServerPageState extends State<ServerPage> with AutomaticKeepAliveClientMi
// Last item is just spacing
if (index == lens) return SizedBox(height: 77);
final vnode = ServerProvider.pick(id: serversInThisColumn[index]);
if (vnode == null) return UIs.placeholder;
final individualState = ref.watch(serverNotifierProvider(serversInThisColumn[index]));
return vnode.listenVal(_buildEachServerCard);
return _buildEachServerCard(individualState);
},
),
);
@@ -186,9 +195,7 @@ class _ServerPageState extends State<ServerPage> with AutomaticKeepAliveClientMi
);
}
Widget _buildEachServerCard(Server? srv) {
if (srv == null) return UIs.placeholder;
Widget _buildEachServerCard(ServerState srv) {
return CardX(
key: Key(srv.spi.id + _tag.value),
child: InkWell(
@@ -218,7 +225,7 @@ class _ServerPageState extends State<ServerPage> with AutomaticKeepAliveClientMi
);
}
Widget _buildRealServerCard(Server srv) {
Widget _buildRealServerCard(ServerState srv) {
final id = srv.spi.id;
final cardStatus = _getCardNoti(id);
final title = _buildServerCardTitle(srv);
@@ -255,7 +262,7 @@ class _ServerPageState extends State<ServerPage> with AutomaticKeepAliveClientMi
});
}
Widget _buildFlippedCard(Server srv) {
Widget _buildFlippedCard(ServerState srv) {
const color = Colors.grey;
const textStyle = TextStyle(fontSize: 13, color: color);
final children = [
@@ -332,8 +339,8 @@ class _ServerPageState extends State<ServerPage> with AutomaticKeepAliveClientMi
@override
Future<void> afterFirstLayout(BuildContext context) async {
ServerProvider.refresh();
ServerProvider.startAutoRefresh();
ref.read(serversNotifierProvider.notifier).refresh();
ref.read(serversNotifierProvider.notifier).startAutoRefresh();
}
static const _kCardHeightMin = 23.0;

View File

@@ -3,7 +3,7 @@
part of 'tab.dart';
extension _Actions on _ServerPageState {
void _onTapCard(Server srv) {
void _onTapCard(ServerState srv) {
if (srv.canViewDetails) {
// _splitViewCtrl.replace(ServerDetailPage(
// key: ValueKey(srv.spi.id),
@@ -19,7 +19,7 @@ extension _Actions on _ServerPageState {
}
}
void _onLongPressCard(Server srv) {
void _onLongPressCard(ServerState srv) {
if (srv.conn == ServerConn.finished) {
final id = srv.spi.id;
final cardStatus = _getCardNoti(id);
@@ -42,7 +42,7 @@ extension _Actions on _ServerPageState {
}
extension _Operation on _ServerPageState {
void _onTapSuspend(Server srv) {
void _onTapSuspend(ServerState srv) {
_askFor(
func: () async {
if (Stores.setting.showSuspendTip.fetch()) {
@@ -50,7 +50,7 @@ extension _Operation on _ServerPageState {
Stores.setting.showSuspendTip.put(false);
}
srv.client?.execWithPwd(
ShellFunc.suspend.exec(srv.spi.id, systemType: srv.status.system),
ShellFunc.suspend.exec(srv.spi.id, systemType: srv.status.system, customDir: null),
context: context,
id: srv.id,
);
@@ -60,10 +60,10 @@ extension _Operation on _ServerPageState {
);
}
void _onTapShutdown(Server srv) {
void _onTapShutdown(ServerState srv) {
_askFor(
func: () => srv.client?.execWithPwd(
ShellFunc.shutdown.exec(srv.spi.id, systemType: srv.status.system),
ShellFunc.shutdown.exec(srv.spi.id, systemType: srv.status.system, customDir: null),
context: context,
id: srv.id,
),
@@ -72,10 +72,10 @@ extension _Operation on _ServerPageState {
);
}
void _onTapReboot(Server srv) {
void _onTapReboot(ServerState srv) {
_askFor(
func: () => srv.client?.execWithPwd(
ShellFunc.reboot.exec(srv.spi.id, systemType: srv.status.system),
ShellFunc.reboot.exec(srv.spi.id, systemType: srv.status.system, customDir: null),
context: context,
id: srv.id,
),
@@ -84,7 +84,7 @@ extension _Operation on _ServerPageState {
);
}
void _onTapEdit(Server srv) {
void _onTapEdit(ServerState srv) {
if (srv.canViewDetails) {
ServerDetailPage.route.go(context, SpiRequiredArgs(srv.spi));
} else {
@@ -98,7 +98,7 @@ extension _Utils on _ServerPageState {
final tag = _tag.value;
if (tag == TagSwitcher.kDefaultTag) return order;
return order.where((e) {
final tags = ServerProvider.pick(id: e)?.value.spi.tags;
final tags = ref.read(serversNotifierProvider).servers[e]?.tags;
if (tags == null) return false;
return tags.contains(tag);
}).toList();
@@ -160,7 +160,7 @@ extension _Utils on _ServerPageState {
}
}
extension _ServerX on Server {
extension _ServerX on ServerState {
String? _getTopRightStr(Spi spi) {
if (status.err != null) {
return l10n.viewErr;

View File

@@ -46,7 +46,7 @@ extension _Server on _AppSettingsPageState {
onTap: () async {
final keys = Stores.server.keys();
final names = Map.fromEntries(
keys.map((e) => MapEntry(e, ServerProvider.pick(id: e)?.value.spi.name ?? e)),
keys.map((e) => MapEntry(e, ref.read(serversNotifierProvider).servers[e]?.name ?? e)),
);
final deleteKeys = await context.showPickDialog<String>(
clearable: true,
@@ -180,6 +180,7 @@ extension _Server on _AppSettingsPageState {
_buildDoubleColumnServersPage(),
_buildUpdateInterval(),
_buildMaxRetry(),
_buildSSHConfigImport(),
],
);
}
@@ -306,4 +307,12 @@ extension _Server on _AppSettingsPageState {
trailing: StoreSwitch(prop: Stores.setting.serverTabPreferDiskAmount),
);
}
Widget _buildSSHConfigImport() {
return ListTile(
title: Text(l10n.sshConfigImport),
subtitle: Text(l10n.sshConfigImportTip, style: UIs.textGrey),
trailing: StoreSwitch(prop: _setting.firstTimeReadSSHCfg),
);
}
}

View File

@@ -4,10 +4,11 @@ import 'dart:io';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_highlight/theme_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/model/app/net_view.dart';
import 'package:server_box/data/provider/server.dart';
import 'package:server_box/data/provider/server/all.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/store.dart';
@@ -35,16 +36,16 @@ part 'entries/ssh.dart';
const _kIconSize = 23.0;
class SettingsPage extends StatefulWidget {
class SettingsPage extends ConsumerStatefulWidget {
const SettingsPage({super.key});
static const route = AppRouteNoArg(page: SettingsPage.new, path: '/settings');
@override
State<SettingsPage> createState() => _SettingsPageState();
ConsumerState<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> with SingleTickerProviderStateMixin {
class _SettingsPageState extends ConsumerState<SettingsPage> with SingleTickerProviderStateMixin {
late final _tabCtrl = TabController(length: SettingsTabs.values.length, vsync: this);
@override
@@ -98,14 +99,14 @@ class _SettingsPageState extends State<SettingsPage> with SingleTickerProviderSt
}
}
final class AppSettingsPage extends StatefulWidget {
final class AppSettingsPage extends ConsumerStatefulWidget {
const AppSettingsPage({super.key});
@override
State<AppSettingsPage> createState() => _AppSettingsPageState();
ConsumerState<AppSettingsPage> createState() => _AppSettingsPageState();
}
final class _AppSettingsPageState extends State<AppSettingsPage> {
final class _AppSettingsPageState extends ConsumerState<AppSettingsPage> {
final _setting = Stores.setting;
late final _sshOpacityCtrl = TextEditingController(text: _setting.sshBgOpacity.fetch().toString());

View File

@@ -1,21 +1,22 @@
import 'dart:ui';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/provider/server.dart';
import 'package:server_box/data/provider/server/all.dart';
import 'package:server_box/data/res/store.dart';
class ServerOrderPage extends StatefulWidget {
class ServerOrderPage extends ConsumerStatefulWidget {
const ServerOrderPage({super.key});
@override
State<ServerOrderPage> createState() => _ServerOrderPageState();
ConsumerState<ServerOrderPage> createState() => _ServerOrderPageState();
static const route = AppRouteNoArg(page: ServerOrderPage.new, path: '/settings/order/server');
}
class _ServerOrderPageState extends State<ServerOrderPage> {
class _ServerOrderPageState extends ConsumerState<ServerOrderPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -41,25 +42,27 @@ class _ServerOrderPageState extends State<ServerOrderPage> {
}
Widget _buildBody() {
final orders = ServerProvider.serverOrder;
return orders.listenVal((order) {
if (order.isEmpty) {
return Center(child: Text(libL10n.empty));
}
return ReorderableListView.builder(
footer: const SizedBox(height: 77),
onReorder: (oldIndex, newIndex) {
setState(() {
orders.value.move(oldIndex, newIndex, property: Stores.setting.serverOrder);
});
},
padding: const EdgeInsets.all(8),
buildDefaultDragHandles: false,
itemBuilder: (_, idx) => _buildItem(idx, order[idx]),
itemCount: order.length,
proxyDecorator: _proxyDecorator,
);
});
final serverState = ref.watch(serversNotifierProvider);
final order = serverState.serverOrder;
if (order.isEmpty) {
return Center(child: Text(libL10n.empty));
}
return ReorderableListView.builder(
footer: const SizedBox(height: 77),
onReorder: (oldIndex, newIndex) {
setState(() {
final newOrder = List<String>.from(order);
newOrder.move(oldIndex, newIndex);
Stores.setting.serverOrder.put(newOrder);
});
},
padding: const EdgeInsets.all(8),
buildDefaultDragHandles: false,
itemBuilder: (_, idx) => _buildItem(idx, order[idx]),
itemCount: order.length,
proxyDecorator: _proxyDecorator,
);
}
Widget _buildItem(int index, String id) {
@@ -74,8 +77,10 @@ class _ServerOrderPageState extends State<ServerOrderPage> {
}
Widget _buildCardTile(int index) {
final id = ServerProvider.serverOrder.value[index];
final spi = ServerProvider.pick(id: id)?.value.spi;
final serverState = ref.watch(serversNotifierProvider);
final order = serverState.serverOrder;
final id = order[index];
final spi = serverState.servers[id];
if (spi == null) {
return const SizedBox();
}

View File

@@ -1,9 +1,10 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/model/server/snippet.dart';
import 'package:server_box/data/provider/server.dart';
import 'package:server_box/data/provider/server/all.dart';
import 'package:server_box/data/provider/snippet.dart';
final class SnippetEditPageArgs {
@@ -11,18 +12,18 @@ final class SnippetEditPageArgs {
const SnippetEditPageArgs({this.snippet});
}
class SnippetEditPage extends StatefulWidget {
class SnippetEditPage extends ConsumerStatefulWidget {
final SnippetEditPageArgs? args;
const SnippetEditPage({super.key, this.args});
@override
State<SnippetEditPage> createState() => _SnippetEditPageState();
ConsumerState<SnippetEditPage> createState() => _SnippetEditPageState();
static const route = AppRoute(page: SnippetEditPage.new, path: '/snippets/edit');
}
class _SnippetEditPageState extends State<SnippetEditPage> with AfterLayoutMixin {
class _SnippetEditPageState extends ConsumerState<SnippetEditPage> with AfterLayoutMixin {
final _nameController = TextEditingController();
final _scriptController = TextEditingController();
final _noteController = TextEditingController();
@@ -61,7 +62,7 @@ class _SnippetEditPageState extends State<SnippetEditPage> with AfterLayoutMixin
child: Text(libL10n.askContinue('${libL10n.delete} ${l10n.snippet}(${snippet.name})')),
actions: Btn.ok(
onTap: () {
SnippetProvider.del(snippet);
ref.read(snippetNotifierProvider.notifier).del(snippet);
context.pop();
context.pop();
},
@@ -95,10 +96,11 @@ class _SnippetEditPageState extends State<SnippetEditPage> with AfterLayoutMixin
autoRunOn: _autoRunOn.value.isEmpty ? null : _autoRunOn.value,
);
final oldSnippet = widget.args?.snippet;
final notifier = ref.read(snippetNotifierProvider.notifier);
if (oldSnippet != null) {
SnippetProvider.update(oldSnippet, snippet);
notifier.update(oldSnippet, snippet);
} else {
SnippetProvider.add(snippet);
notifier.add(snippet);
}
context.pop();
},
@@ -126,7 +128,12 @@ class _SnippetEditPageState extends State<SnippetEditPage> with AfterLayoutMixin
icon: Icons.note,
suggestion: true,
),
TagTile(tags: _tags, allTags: SnippetProvider.tags.value).cardx,
Consumer(
builder: (_, ref, _) {
final tags = ref.watch(snippetNotifierProvider.select((p) => p.tags));
return TagTile(tags: _tags, allTags: tags).cardx;
},
),
Input(
controller: _scriptController,
node: _scriptNode,
@@ -150,7 +157,7 @@ class _SnippetEditPageState extends State<SnippetEditPage> with AfterLayoutMixin
builder: (vals) {
final subtitle = vals.isEmpty
? null
: vals.map((e) => ServerProvider.pick(id: e)?.value.spi.name ?? e).join(', ');
: vals.map((e) => ref.read(serversNotifierProvider).servers[e]?.name ?? e).join(', ');
return ListTile(
leading: const Padding(
padding: EdgeInsets.only(left: 5),
@@ -162,12 +169,13 @@ class _SnippetEditPageState extends State<SnippetEditPage> with AfterLayoutMixin
? null
: Text(subtitle, maxLines: 1, style: UIs.textGrey, overflow: TextOverflow.ellipsis),
onTap: () async {
vals.removeWhere((e) => !ServerProvider.serverOrder.value.contains(e));
// Create a filtered copy for the dialog, don't modify the original
final validServerIds = vals.where((e) => ref.read(serversNotifierProvider).serverOrder.contains(e)).toList();
final serverIds = await context.showPickDialog(
title: l10n.autoRun,
items: ServerProvider.serverOrder.value,
display: (e) => ServerProvider.pick(id: e)?.value.spi.name ?? e,
initial: vals,
items: ref.read(serversNotifierProvider).serverOrder,
display: (e) => ref.read(serversNotifierProvider).servers[e]?.name ?? e,
initial: validServerIds,
clearable: true,
);
if (serverIds != null) {

View File

@@ -1,20 +1,21 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:server_box/data/model/server/snippet.dart';
import 'package:server_box/data/provider/snippet.dart';
import 'package:server_box/view/page/snippet/edit.dart';
class SnippetListPage extends StatefulWidget {
class SnippetListPage extends ConsumerStatefulWidget {
const SnippetListPage({super.key});
@override
State<SnippetListPage> createState() => _SnippetListPageState();
ConsumerState<SnippetListPage> createState() => _SnippetListPageState();
static const route = AppRouteNoArg(page: SnippetListPage.new, path: '/snippets');
}
class _SnippetListPageState extends State<SnippetListPage> with AutomaticKeepAliveClientMixin {
class _SnippetListPageState extends ConsumerState<SnippetListPage> with AutomaticKeepAliveClientMixin {
final _tag = ''.vn;
final _splitViewCtrl = SplitViewController();
@@ -35,12 +36,14 @@ class _SnippetListPageState extends State<SnippetListPage> with AutomaticKeepAli
Widget _buildBody() {
// final isMobile = ResponsiveBreakpoints.of(context).isMobile;
return SnippetProvider.snippets.listenVal((snippets) {
return _tag.listenVal((tag) {
final child = _buildScaffold(snippets, tag);
// if (isMobile) {
return child;
// }
final snippetState = ref.watch(snippetNotifierProvider);
final snippets = snippetState.snippets;
return _tag.listenVal((tag) {
final child = _buildScaffold(snippets, tag);
// if (isMobile) {
return child;
// }
// return SplitView(
// controller: _splitViewCtrl,
@@ -49,14 +52,14 @@ class _SnippetListPageState extends State<SnippetListPage> with AutomaticKeepAli
// initialRight: Center(child: Text(libL10n.empty)),
// leftBuilder: (_, __) => child,
// );
});
});
}
Widget _buildScaffold(List<Snippet> snippets, String tag) {
final snippetState = ref.watch(snippetNotifierProvider);
return Scaffold(
appBar: TagSwitcher(
tags: SnippetProvider.tags,
tags: snippetState.tags.vn,
onTagChanged: (tag) => _tag.value = tag,
initTag: _tag.value,
),

View File

@@ -66,7 +66,8 @@ extension _Init on SSHPageState {
// Mark status connected for notifications / live activities
TermSessionManager.updateStatus(_sessionId, TermSessionStatus.connected);
for (final snippet in SnippetProvider.snippets.value) {
final snippets = ref.read(snippetNotifierProvider.select((p) => p.snippets));
for (final snippet in snippets) {
if (snippet.autoRunOn?.contains(widget.args.spi.id) == true) {
snippet.runInTerm(_terminal, widget.args.spi);
}

View File

@@ -7,9 +7,8 @@ import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:provider/provider.dart';
import 'package:server_box/core/chan.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/utils/server.dart';
@@ -17,6 +16,7 @@ import 'package:server_box/core/utils/ssh_auth.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/snippet.dart';
import 'package:server_box/data/model/ssh/virtual_key.dart';
import 'package:server_box/data/provider/server/single.dart';
import 'package:server_box/data/provider/snippet.dart';
import 'package:server_box/data/provider/virtual_keyboard.dart';
import 'package:server_box/data/res/store.dart';
@@ -52,23 +52,22 @@ final class SshPageArgs {
});
}
class SSHPage extends StatefulWidget {
class SSHPage extends ConsumerStatefulWidget {
final SshPageArgs args;
const SSHPage({super.key, required this.args});
@override
State<SSHPage> createState() => SSHPageState();
ConsumerState<SSHPage> createState() => SSHPageState();
static const route = AppRouteArg<void, SshPageArgs>(page: SSHPage.new, path: '/ssh/page');
}
const _horizonPadding = 7.0;
class SSHPageState extends State<SSHPage>
class SSHPageState extends ConsumerState<SSHPage>
with AutomaticKeepAliveClientMixin, AfterLayoutMixin, TickerProviderStateMixin {
final _keyboard = VirtKeyProvider();
late final _terminal = Terminal(inputHandler: _keyboard);
late final _terminal = Terminal();
late final TerminalController _terminalController = TerminalController(vsync: this);
final List<List<VirtKey>> _virtKeysList = [];
late final _termKey = widget.args.terminalKey ?? GlobalKey<TerminalViewState>();
@@ -81,7 +80,7 @@ class SSHPageState extends State<SSHPage>
bool _isDark = false;
Timer? _virtKeyLongPressTimer;
late SSHClient? _client = widget.args.spi.server?.value.client;
SSHClient? _client;
SSHSession? _session;
Timer? _discontinuityTimer;
@@ -117,6 +116,10 @@ class SSHPageState extends State<SSHPage>
_initStoredCfg();
_initVirtKeys();
_setupDiscontinuityTimer();
// Initialize client from provider
final serverState = ref.read(serverNotifierProvider(widget.args.spi.id));
_client = serverState.client;
if (++_sshConnCount == 1) {
WakelockPlus.enable();
@@ -262,19 +265,22 @@ class SSHPageState extends State<SSHPage>
child: Container(
color: _terminalTheme.background,
height: _virtKeysHeight + _media.padding.bottom,
child: ChangeNotifierProvider(
create: (_) => _keyboard,
builder: (_, _) => Consumer<VirtKeyProvider>(
builder: (_, _, _) {
return _buildVirtualKey();
},
),
child: Consumer(
builder: (context, ref, child) {
final virtKeyState = ref.watch(virtKeyboardProvider);
final virtKeyNotifier = ref.read(virtKeyboardProvider.notifier);
// Set the terminal input handler
_terminal.inputHandler = virtKeyNotifier;
return _buildVirtualKey(virtKeyState, virtKeyNotifier);
},
),
),
);
}
Widget _buildVirtualKey() {
Widget _buildVirtualKey(VirtKeyState virtKeyState, VirtKeyboard virtKeyNotifier) {
final count = _horizonVirtKeys ? _virtKeysList.length : _virtKeysList.firstOrNull?.length ?? 0;
if (count == 0) return UIs.placeholder;
return LayoutBuilder(
@@ -286,30 +292,30 @@ class SSHPageState extends State<SSHPage>
child: Row(
children: _virtKeysList
.expand((e) => e)
.map((e) => _buildVirtKeyItem(e, virtKeyWidth))
.map((e) => _buildVirtKeyItem(e, virtKeyWidth, virtKeyState, virtKeyNotifier))
.toList(),
),
);
}
final rows = _virtKeysList
.map((e) => Row(children: e.map((e) => _buildVirtKeyItem(e, virtKeyWidth)).toList()))
.map((e) => Row(children: e.map((e) => _buildVirtKeyItem(e, virtKeyWidth, virtKeyState, virtKeyNotifier)).toList()))
.toList();
return Column(mainAxisSize: MainAxisSize.min, children: rows);
},
);
}
Widget _buildVirtKeyItem(VirtKey item, double virtKeyWidth) {
Widget _buildVirtKeyItem(VirtKey item, double virtKeyWidth, VirtKeyState virtKeyState, VirtKeyboard virtKeyNotifier) {
var selected = false;
switch (item.key) {
case TerminalKey.control:
selected = _keyboard.ctrl;
selected = virtKeyState.ctrl;
break;
case TerminalKey.alt:
selected = _keyboard.alt;
selected = virtKeyState.alt;
break;
case TerminalKey.shift:
selected = _keyboard.shift;
selected = virtKeyState.shift;
break;
default:
break;
@@ -326,12 +332,12 @@ class SSHPageState extends State<SSHPage>
);
return InkWell(
onTap: () => _doVirtualKey(item),
onTap: () => _doVirtualKey(item, virtKeyNotifier),
onTapDown: (details) {
if (item.canLongPress) {
_virtKeyLongPressTimer = Timer.periodic(
const Duration(milliseconds: 137),
(_) => _doVirtualKey(item),
(_) => _doVirtualKey(item, virtKeyNotifier),
);
}
},

View File

@@ -1,7 +1,7 @@
part of 'page.dart';
extension _VirtKey on SSHPageState {
void _doVirtualKey(VirtKey item) {
void _doVirtualKey(VirtKey item, VirtKeyboard virtKeyNotifier) {
if (item.func != null) {
HapticFeedback.mediumImpact();
_doVirtualKeyFunc(item.func!);
@@ -9,7 +9,7 @@ extension _VirtKey on SSHPageState {
}
if (item.key != null) {
HapticFeedback.mediumImpact();
_doVirtualKeyInput(item.key!);
_doVirtualKeyInput(item.key!, virtKeyNotifier);
}
final inputRaw = item.inputRaw;
if (inputRaw != null) {
@@ -18,16 +18,16 @@ extension _VirtKey on SSHPageState {
}
}
void _doVirtualKeyInput(TerminalKey key) {
void _doVirtualKeyInput(TerminalKey key, VirtKeyboard virtKeyNotifier) {
switch (key) {
case TerminalKey.control:
_keyboard.ctrl = !_keyboard.ctrl;
virtKeyNotifier.setCtrl(!virtKeyNotifier.ctrl);
break;
case TerminalKey.alt:
_keyboard.alt = !_keyboard.alt;
virtKeyNotifier.setAlt(!virtKeyNotifier.alt);
break;
case TerminalKey.shift:
_keyboard.shift = !_keyboard.shift;
virtKeyNotifier.setShift(!virtKeyNotifier.shift);
break;
default:
_terminal.keyInput(key);
@@ -52,14 +52,15 @@ extension _VirtKey on SSHPageState {
}
break;
case VirtualKeyFunc.snippet:
final snippetState = ref.read(snippetNotifierProvider);
final snippets = await context.showPickWithTagDialog<Snippet>(
title: l10n.snippet,
tags: SnippetProvider.tags,
tags: snippetState.tags.vn,
itemsBuilder: (e) {
if (e == TagSwitcher.kDefaultTag) {
return SnippetProvider.snippets.value;
return snippetState.snippets;
}
return SnippetProvider.snippets.value
return snippetState.snippets
.where((element) => element.tags?.contains(e) ?? false)
.toList();
},

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